Compare commits
9 Commits
494a54fa46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 914f10f502 | |||
| e3dbedc59f | |||
| 73d820ddaa | |||
| f5ceb872e0 | |||
| e79d15de2e | |||
| 3c6df2e777 | |||
| 1fb9919ec3 | |||
| 932f08642c | |||
| d63c467ef9 |
@@ -268,6 +268,103 @@
|
|||||||
- backend debug broadcasts
|
- backend debug broadcasts
|
||||||
|
|
||||||
## Recent Change Log
|
## Recent Change Log
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Removed the remaining inline `Powered by GIPHY` / prompt-chip bar from Zone A image mode so the image search view now shows only the shared search controls and the results area.
|
||||||
|
- Why it changed:
|
||||||
|
- The user wanted that image-mode top strip removed entirely instead of reduced or restyled.
|
||||||
|
- How it was verified:
|
||||||
|
- static review of `frontend/index.html` and `frontend/app.js`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- None beyond the usual need for a browser hard refresh if an older cached frontend bundle is still open in a tab.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Restored the video-search request path to tolerate a scheme-less `SEARXNG_BASE_URL` such as `192.168.1.66:8087` by normalizing it to `http://...` during search-service initialization.
|
||||||
|
- Added regression coverage so the video search service keeps accepting the older style base URL configuration used in live deployment.
|
||||||
|
- Why it changed:
|
||||||
|
- Real user logs showed video search failing immediately with `first path segment in URL cannot contain colon`, which traced back to a scheme-less SearXNG base URL in the deployed environment.
|
||||||
|
- How it was verified:
|
||||||
|
- log review of `ai-media-hub-2026-03-24T08-09-23-204Z.log`
|
||||||
|
- added unit coverage for scheme-less base URL normalization
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- Go tests could not be rerun in this environment because `go` is currently unavailable here, so this fix is verified by code-path review plus the added test only.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Corrected the Unraid template GIPHY download path mapping from `/downloads/giphy` to `/app/downloads/giphy` so it matches the backend default download directory layout.
|
||||||
|
- Why it changed:
|
||||||
|
- The previous template target path dropped the `/app` prefix and did not match the application’s runtime default for `GIPHY_DOWNLOAD_DIR`.
|
||||||
|
- How it was verified:
|
||||||
|
- static review of `unraid-template.xml`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- Existing Unraid installs that already created the older path mapping may need their template field refreshed or reapplied to align with the corrected container path.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Removed the large GIPHY image-mode info box entirely and replaced it with a minimal inline prompt bar plus `Powered by GIPHY` label.
|
||||||
|
- Hardened frontend visibility toggling so stale cached HTML/JS combinations do not crash on missing elements.
|
||||||
|
- Bumped the frontend asset version again so browsers are forced onto the latest image-search UI bundle after the GIPHY panel changes.
|
||||||
|
- Why it changed:
|
||||||
|
- Real user logs showed a client-side `Cannot read properties of null (reading 'classList')` error caused by stale frontend asset mismatch, which prevented image results from rendering, and the remaining large image-mode box was still not desired in the UI.
|
||||||
|
- How it was verified:
|
||||||
|
- log review of `ai-media-hub-2026-03-24T07-48-19-085Z.log`
|
||||||
|
- `node --check frontend/app.js`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- Users with aggressively cached browser sessions may still need one hard refresh to fully drop older HTML/JS combinations already loaded in an open tab.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Simplified the GIPHY image-search UX so it presents raw search results instead of looking like an AI-evaluated result flow.
|
||||||
|
- Updated the image-mode copy to describe direct GIPHY search results, and changed the shared preview modal labels/content for GIPHY items from AI-note style metadata to plain result/source info.
|
||||||
|
- Why it changed:
|
||||||
|
- The image-search experience should behave like a straightforward provider search result browser, not like the video-side Gemini review flow.
|
||||||
|
- How it was verified:
|
||||||
|
- `node --check frontend/app.js`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- This is a UX clarification pass; the backend still uses Gemini only for multilingual query expansion and does not do visual evaluation on GIPHY items.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Relaxed Gemini image-query expansion parsing so loose plain-text numbered lists can still be accepted when the model prepends explanatory text instead of returning a clean JSON object.
|
||||||
|
- Removed the GIPHY image-mode search meta box from the frontend so the image UI stays visually simpler.
|
||||||
|
- Stopped surfacing the Gemini image-expansion fallback warning directly in the image-search UI when the backend can still continue with usable fallback queries.
|
||||||
|
- Why it changed:
|
||||||
|
- Real log review showed Gemini image expansion sometimes returned text like `Here is the JSON requested`, which triggered fallback even though the model output still contained useful query candidates, and the extra meta box was not adding enough value to justify the space it consumed.
|
||||||
|
- How it was verified:
|
||||||
|
- log review of `ai-media-hub-2026-03-24T07-25-42-827Z.log`
|
||||||
|
- `node --check frontend/app.js`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- This improves tolerance for one common Gemini formatting deviation, but fully free-form model output can still fall back if it does not contain recoverable query lines.
|
||||||
|
- Go tests still could not be rerun in this environment because `go` is currently unavailable here.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Removed the redundant `GIPHY Download Dir` variable field from the Unraid template and kept the dedicated `GIPHY Downloads` path mapping as the single user-facing download-path control.
|
||||||
|
- Why it changed:
|
||||||
|
- The earlier template exposed both a path mapping and a matching container-path variable for the same GIPHY download location, which was unnecessarily confusing in Unraid.
|
||||||
|
- How it was verified:
|
||||||
|
- static review of `unraid-template.xml`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- The application still supports `GIPHY_DOWNLOAD_DIR` as an environment variable, but the Unraid template now intentionally relies on the path mapping plus the backend default container path to reduce duplicated inputs.
|
||||||
|
|
||||||
|
- Date: `2026-03-24`
|
||||||
|
- What changed:
|
||||||
|
- Replaced the earlier frontend-only image prototype with an integrated GIPHY image/GIF search flow.
|
||||||
|
- Added backend GIPHY search aggregation with dedupe, up to `100` results, secure download handling, and Gemini-driven multilingual image query expansion into exactly `5` English queries.
|
||||||
|
- Reused the existing result modal for enlarged GIPHY preview and download actions, and added an internal-scroll image results panel with visible `Powered by GIPHY` attribution.
|
||||||
|
- Updated startup config and Unraid template fields for `GIPHY_ENABLED`, `GIPHY_API_KEY`, `GIPHY_MAX_RESULTS`, `GIPHY_RATING`, `GIPHY_LANG`, `GIPHY_DOWNLOAD_DIR`, and `GEMINI_MODEL`.
|
||||||
|
- Added backend tests covering Gemini image-expansion parsing/fallback, GIPHY aggregation/cap behavior, download validation, and handler-level API paths.
|
||||||
|
- Why it changed:
|
||||||
|
- The app needed a production-usable image/GIF provider added incrementally without breaking the existing video search experience.
|
||||||
|
- How it was verified:
|
||||||
|
- `node --check frontend/app.js`
|
||||||
|
- static code review of backend/frontend wiring and new test coverage
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- This environment currently does not expose `go` or `gofmt`, so Go formatting and `go test ./...` could not be rerun locally in this turn even though new tests were added.
|
||||||
|
- Live browser QA and real GIPHY credential validation still need to be performed in a runtime environment with working API keys.
|
||||||
|
- Push of commit `d63c467` failed on `2026-03-24` with remote error `unable to create temporary object directory` / `unpacker error`, so the latest GIPHY feature batch is currently local-only until the remote accepts a retry.
|
||||||
|
|
||||||
- Date: `2026-03-24`
|
- Date: `2026-03-24`
|
||||||
- What changed:
|
- What changed:
|
||||||
- Added a working-rule note that git push authentication for this repository should be retried with the local credential file `.local/git-credentials.psd1` before leaving work in a local-only state.
|
- Added a working-rule note that git push authentication for this repository should be retried with the local credential file `.local/git-credentials.psd1` before leaving work in a local-only state.
|
||||||
@@ -460,6 +557,13 @@
|
|||||||
- `SEARXNG_GOOGLE_VIDEO_ENGINE`
|
- `SEARXNG_GOOGLE_VIDEO_ENGINE`
|
||||||
- `SEARXNG_WEB_ENGINE`
|
- `SEARXNG_WEB_ENGINE`
|
||||||
- `GEMINI_API_KEY`
|
- `GEMINI_API_KEY`
|
||||||
|
- `GEMINI_MODEL`
|
||||||
|
- `GIPHY_ENABLED`
|
||||||
|
- `GIPHY_API_KEY`
|
||||||
|
- `GIPHY_MAX_RESULTS`
|
||||||
|
- `GIPHY_RATING`
|
||||||
|
- `GIPHY_LANG`
|
||||||
|
- `GIPHY_DOWNLOAD_DIR`
|
||||||
|
|
||||||
## Local Development Environment Notes
|
## Local Development Environment Notes
|
||||||
- This machine started without `go`, `pip`, `ffmpeg`, `yt-dlp`, or `node`.
|
- This machine started without `go`, `pip`, `ffmpeg`, `yt-dlp`, or `node`.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type App struct {
|
|||||||
WorkerScript string
|
WorkerScript string
|
||||||
SearchService *services.SearchService
|
SearchService *services.SearchService
|
||||||
GeminiService *services.GeminiService
|
GeminiService *services.GeminiService
|
||||||
|
GiphyService *services.GiphyService
|
||||||
Hub *Hub
|
Hub *Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +150,8 @@ func RegisterRoutes(router *gin.Engine, app *App) {
|
|||||||
router.POST("/api/download", app.startDownload)
|
router.POST("/api/download", app.startDownload)
|
||||||
router.POST("/api/translate/summary", app.translateSummary)
|
router.POST("/api/translate/summary", app.translateSummary)
|
||||||
router.POST("/api/search", app.searchMedia)
|
router.POST("/api/search", app.searchMedia)
|
||||||
|
router.POST("/api/giphy/search", app.searchGiphy)
|
||||||
|
router.POST("/api/giphy/download", app.downloadGiphy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) debug(message string, data any) {
|
func (a *App) debug(message string, data any) {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ai-media-hub/backend/models"
|
||||||
|
"ai-media-hub/backend/services"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) searchGiphy(c *gin.Context) {
|
||||||
|
if a.GiphyService == nil || !a.GiphyService.Config.Enabled {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "giphy search is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
MaxResults int `json:"maxResults"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Query = strings.TrimSpace(req.Query)
|
||||||
|
if req.Query == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "expanding query for GIPHY", "progress": 20})
|
||||||
|
resp, err := a.GiphyService.SearchImages(req.Query, req.MaxResults)
|
||||||
|
if err != nil {
|
||||||
|
a.debug("giphy:search_error", gin.H{"query": req.Query, "error": err.Error()})
|
||||||
|
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search failed", "progress": 100, "message": err.Error()})
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.debug("giphy:search_complete", gin.H{
|
||||||
|
"query": req.Query,
|
||||||
|
"expandedQueries": resp.ExpandedQueries,
|
||||||
|
"total": resp.Total,
|
||||||
|
"warning": resp.Warning,
|
||||||
|
})
|
||||||
|
a.Hub.Broadcast("progress", gin.H{"type": "giphy-search", "status": "giphy search complete", "progress": 100, "count": resp.Total})
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) downloadGiphy(c *gin.Context) {
|
||||||
|
if a.GiphyService == nil || !a.GiphyService.Config.Enabled {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"ok": false, "error": "GIPHY_DISABLED", "message": "GIPHY download is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ProviderID string `json:"providerId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
OriginalQuery string `json:"originalQuery"`
|
||||||
|
SelectedExpansionQuery string `json:"selectedExpansionQuery"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_REQUEST", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.GiphyService.DownloadMedia(services.GiphyDownloadRequest{
|
||||||
|
ProviderID: req.ProviderID,
|
||||||
|
Title: req.Title,
|
||||||
|
DownloadURL: req.DownloadURL,
|
||||||
|
OriginalQuery: req.OriginalQuery,
|
||||||
|
SelectedExpansionQuery: req.SelectedExpansionQuery,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.debug("giphy:download_error", gin.H{
|
||||||
|
"providerId": req.ProviderID,
|
||||||
|
"title": req.Title,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if resp.Error == "INVALID_REQUEST" || resp.Error == "INVALID_DOWNLOAD_URL" {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
c.JSON(status, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.DB != nil {
|
||||||
|
if recordID, dbErr := models.InsertDownload(a.DB, req.DownloadURL, "GIPHY", resp.SavedPath, "completed"); dbErr == nil {
|
||||||
|
_ = models.MarkDownloadCompleted(a.DB, recordID, "completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Hub.Broadcast("progress", gin.H{"type": "giphy-download", "status": "giphy download complete", "progress": 100, "fileName": resp.FileName})
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ai-media-hub/backend/services"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchGiphyEndpointReturnsExpandedQueriesAndItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"happy dog\",\"happy dog gif\",\"dog reaction\",\"dog meme gif\",\"animated dog sticker\"]}"}]}}]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"dog-1","title":"Happy Dog","slug":"happy-dog","rating":"g","url":"https://giphy.com/gifs/dog-1","images":{"original":{"url":"https://media.giphy.com/media/dog-1/giphy.gif","width":"480","height":"270"},"fixed_width":{"url":"https://media.giphy.com/media/dog-1/200w.gif","width":"200","height":"113"},"fixed_width_still":{"url":"https://media.giphy.com/media/dog-1/200w_s.gif"}}}]}`))
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
gemini := services.NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||||
|
gemini.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
gemini.GenerateEndpoint = server.URL + "/generate"
|
||||||
|
|
||||||
|
giphy := services.NewGiphyService(services.GiphyConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIKey: "test-key",
|
||||||
|
MaxResults: 100,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
}, gemini)
|
||||||
|
giphy.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
|
||||||
|
app := &App{GiphyService: giphy, Hub: NewHub()}
|
||||||
|
router := gin.New()
|
||||||
|
RegisterRoutes(router, app)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"query":"행복한 강아지","maxResults":100}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/giphy/search", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ExpandedQueries []string `json:"expandedQueries"`
|
||||||
|
Items []services.GiphyResult `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.ExpandedQueries) != 5 || len(payload.Items) == 0 {
|
||||||
|
t.Fatalf("unexpected payload: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadGiphyEndpointRejectsNonGiphyHost(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
app := &App{
|
||||||
|
GiphyService: services.NewGiphyService(services.GiphyConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIKey: "test-key",
|
||||||
|
DownloadDir: t.TempDir(),
|
||||||
|
}, nil),
|
||||||
|
Hub: NewHub(),
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
RegisterRoutes(router, app)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"providerId":"x","title":"bad","downloadUrl":"https://example.com/file.gif"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/giphy/download", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "INVALID_DOWNLOAD_URL") {
|
||||||
|
t.Fatalf("expected invalid host error, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+60
-1
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"ai-media-hub/backend/handlers"
|
"ai-media-hub/backend/handlers"
|
||||||
"ai-media-hub/backend/models"
|
"ai-media-hub/backend/models"
|
||||||
@@ -17,8 +19,16 @@ func main() {
|
|||||||
root := envOrDefault("APP_ROOT", "/app")
|
root := envOrDefault("APP_ROOT", "/app")
|
||||||
dbPath := envOrDefault("SQLITE_PATH", filepath.Join(root, "db", "media.db"))
|
dbPath := envOrDefault("SQLITE_PATH", filepath.Join(root, "db", "media.db"))
|
||||||
downloadsDir := envOrDefault("DOWNLOADS_DIR", filepath.Join(root, "downloads"))
|
downloadsDir := envOrDefault("DOWNLOADS_DIR", filepath.Join(root, "downloads"))
|
||||||
|
giphyDownloadDir := envOrDefault("GIPHY_DOWNLOAD_DIR", filepath.Join(downloadsDir, "giphy"))
|
||||||
frontendDir := envOrDefault("FRONTEND_DIR", filepath.Join(root, "frontend"))
|
frontendDir := envOrDefault("FRONTEND_DIR", filepath.Join(root, "frontend"))
|
||||||
workerScript := envOrDefault("WORKER_SCRIPT", filepath.Join(root, "worker", "downloader.py"))
|
workerScript := envOrDefault("WORKER_SCRIPT", filepath.Join(root, "worker", "downloader.py"))
|
||||||
|
geminiAPIKey := os.Getenv("GEMINI_API_KEY")
|
||||||
|
geminiModel := envOrDefault("GEMINI_MODEL", "gemini-2.5-flash")
|
||||||
|
giphyEnabled := envBoolOrDefault("GIPHY_ENABLED", true)
|
||||||
|
giphyAPIKey := os.Getenv("GIPHY_API_KEY")
|
||||||
|
giphyMaxResults := envIntOrDefault("GIPHY_MAX_RESULTS", 100)
|
||||||
|
giphyRating := envOrDefault("GIPHY_RATING", "g")
|
||||||
|
giphyLang := envOrDefault("GIPHY_LANG", "en")
|
||||||
|
|
||||||
db, err := models.InitDB(dbPath)
|
db, err := models.InitDB(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -29,6 +39,25 @@ func main() {
|
|||||||
if err := handlers.EnsurePaths(downloadsDir, workerScript); err != nil {
|
if err := handlers.EnsurePaths(downloadsDir, workerScript); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if geminiAPIKey == "" {
|
||||||
|
log.Printf("warning: GEMINI_API_KEY is not configured; query expansion will use fallback behavior")
|
||||||
|
}
|
||||||
|
if giphyEnabled && strings.TrimSpace(giphyAPIKey) == "" {
|
||||||
|
log.Fatal("GIPHY_ENABLED is true but GIPHY_API_KEY is not configured")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(giphyDownloadDir, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiService := services.NewGeminiService(geminiAPIKey, geminiModel)
|
||||||
|
giphyService := services.NewGiphyService(services.GiphyConfig{
|
||||||
|
Enabled: giphyEnabled,
|
||||||
|
APIKey: giphyAPIKey,
|
||||||
|
MaxResults: giphyMaxResults,
|
||||||
|
Rating: giphyRating,
|
||||||
|
Lang: giphyLang,
|
||||||
|
DownloadDir: giphyDownloadDir,
|
||||||
|
}, geminiService)
|
||||||
|
|
||||||
app := &handlers.App{
|
app := &handlers.App{
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -40,7 +69,8 @@ func main() {
|
|||||||
os.Getenv("SEARXNG_GOOGLE_VIDEO_ENGINE"),
|
os.Getenv("SEARXNG_GOOGLE_VIDEO_ENGINE"),
|
||||||
os.Getenv("SEARXNG_WEB_ENGINE"),
|
os.Getenv("SEARXNG_WEB_ENGINE"),
|
||||||
),
|
),
|
||||||
GeminiService: services.NewGeminiService(os.Getenv("GEMINI_API_KEY")),
|
GeminiService: geminiService,
|
||||||
|
GiphyService: giphyService,
|
||||||
Hub: handlers.NewHub(),
|
Hub: handlers.NewHub(),
|
||||||
}
|
}
|
||||||
app.SearchService.Debug = func(message string, data any) {
|
app.SearchService.Debug = func(message string, data any) {
|
||||||
@@ -49,6 +79,9 @@ func main() {
|
|||||||
app.GeminiService.Debug = func(message string, data any) {
|
app.GeminiService.Debug = func(message string, data any) {
|
||||||
app.Hub.Broadcast("debug", gin.H{"message": message, "data": data})
|
app.Hub.Broadcast("debug", gin.H{"message": message, "data": data})
|
||||||
}
|
}
|
||||||
|
app.GiphyService.Debug = func(message string, data any) {
|
||||||
|
app.Hub.Broadcast("debug", gin.H{"message": message, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
handlers.RegisterRoutes(router, app)
|
handlers.RegisterRoutes(router, app)
|
||||||
@@ -75,3 +108,29 @@ func envOrDefault(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envBoolOrDefault(key string, fallback bool) bool {
|
||||||
|
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||||
|
switch value {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
case "":
|
||||||
|
return fallback
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOrDefault(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|||||||
+12
-1
@@ -63,7 +63,7 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
|||||||
webEngine = "google"
|
webEngine = "google"
|
||||||
}
|
}
|
||||||
return &SearchService{
|
return &SearchService{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: normalizeBaseURL(baseURL),
|
||||||
GoogleVideoEngine: googleVideoEngine,
|
GoogleVideoEngine: googleVideoEngine,
|
||||||
WebEngine: webEngine,
|
WebEngine: webEngine,
|
||||||
Client: &http.Client{Timeout: 20 * time.Second},
|
Client: &http.Client{Timeout: 20 * time.Second},
|
||||||
@@ -77,6 +77,17 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(trimmed, "://") {
|
||||||
|
trimmed = "http://" + trimmed
|
||||||
|
}
|
||||||
|
return strings.TrimRight(trimmed, "/")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, SearchExecutionMeta, 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{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ func TestSearchServiceFetchCacheRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewSearchServiceNormalizesSchemeLessBaseURL(t *testing.T) {
|
||||||
|
service := NewSearchService("192.168.1.66:8087", "", "")
|
||||||
|
if service.BaseURL != "http://192.168.1.66:8087" {
|
||||||
|
t.Fatalf("expected normalized base url, got %q", service.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSplitSearchDeadlinesReservesEnrichmentWindow(t *testing.T) {
|
func TestSplitSearchDeadlinesReservesEnrichmentWindow(t *testing.T) {
|
||||||
deadline := time.Now().Add(20 * time.Second)
|
deadline := time.Now().Add(20 * time.Second)
|
||||||
collectionDeadline, enrichmentDeadline := splitSearchDeadlines(deadline)
|
collectionDeadline, enrichmentDeadline := splitSearchDeadlines(deadline)
|
||||||
|
|||||||
+109
-2
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
type GeminiService struct {
|
type GeminiService struct {
|
||||||
APIKey string
|
APIKey string
|
||||||
|
Model string
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
GenerateEndpoint string
|
GenerateEndpoint string
|
||||||
TranslateEndpoint string
|
TranslateEndpoint string
|
||||||
@@ -69,11 +70,15 @@ type QueryExpansion struct {
|
|||||||
Querywords []string `json:"querywords"`
|
Querywords []string `json:"querywords"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGeminiService(apiKey string) *GeminiService {
|
func NewGeminiService(apiKey, model string) *GeminiService {
|
||||||
|
if strings.TrimSpace(model) == "" {
|
||||||
|
model = "gemini-2.5-flash"
|
||||||
|
}
|
||||||
return &GeminiService{
|
return &GeminiService{
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
|
Model: model,
|
||||||
Client: &http.Client{Timeout: 40 * time.Second},
|
Client: &http.Client{Timeout: 40 * time.Second},
|
||||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
|
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent",
|
||||||
TranslateEndpoint: "https://translate.googleapis.com/translate_a/single",
|
TranslateEndpoint: "https://translate.googleapis.com/translate_a/single",
|
||||||
visualCache: map[string]cachedVisualData{},
|
visualCache: map[string]cachedVisualData{},
|
||||||
translationCache: map[string]cachedStringValue{},
|
translationCache: map[string]cachedStringValue{},
|
||||||
@@ -99,6 +104,80 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
|||||||
return expanded, nil
|
return expanded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GeminiService) ExpandImageQueries(query string) ([]string, error) {
|
||||||
|
trimmed := strings.TrimSpace(query)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, fmt.Errorf("query is empty")
|
||||||
|
}
|
||||||
|
cacheKey := "image-expansion\n" + trimmed
|
||||||
|
if cached, ok := g.getCachedExpansion(cacheKey); ok {
|
||||||
|
g.debug("gemini:image_expand_cache_hit", map[string]any{"query": trimmed, "expanded": cached})
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := buildFallbackImageQueries(trimmed, g.TranslateQuery(trimmed))
|
||||||
|
if g.APIKey == "" {
|
||||||
|
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||||
|
return fallback, fmt.Errorf("gemini api key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"systemInstruction": map[string]any{
|
||||||
|
"parts": []map[string]string{{
|
||||||
|
"text": "Return exactly 5 concise English search queries for GIPHY image or GIF search. Respond with JSON only in this shape: {\"queries\":[\"...\",\"...\",\"...\",\"...\",\"...\"]}. Keep the queries meaning-preserving, practical, deduplicated, and concise.",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"contents": []map[string]any{{
|
||||||
|
"parts": []map[string]string{{
|
||||||
|
"text": "User query: " + trimmed + "\nGenerate exactly 5 English search queries for GIPHY image or GIF search. Include a direct translation, a common phrasing, and only relevant related variants.",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
"generationConfig": map[string]any{
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"maxOutputTokens": 160,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rawText, err := g.generateText(body)
|
||||||
|
if err != nil {
|
||||||
|
g.debug("gemini:image_expand_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||||
|
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||||
|
return fallback, err
|
||||||
|
}
|
||||||
|
jsonText, err := extractJSONObject(rawText)
|
||||||
|
if err != nil {
|
||||||
|
if looseQueries := parseLooseImageExpansionLines(rawText); len(looseQueries) == 5 {
|
||||||
|
g.setCachedExpansion(cacheKey, looseQueries, 15*time.Minute)
|
||||||
|
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": looseQueries, "mode": "loose_text"})
|
||||||
|
return looseQueries, nil
|
||||||
|
}
|
||||||
|
g.debug("gemini:image_expand_parse_error", map[string]any{"query": trimmed, "error": err.Error()})
|
||||||
|
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||||
|
return fallback, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Queries []string `json:"queries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &payload); err != nil {
|
||||||
|
g.debug("gemini:image_expand_json_error", map[string]any{"query": trimmed, "error": err.Error(), "raw": truncateForError(rawText, 200)})
|
||||||
|
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||||
|
return fallback, err
|
||||||
|
}
|
||||||
|
queries := normalizeImageExpansionQueries(payload.Queries)
|
||||||
|
if len(queries) != 5 {
|
||||||
|
err := fmt.Errorf("gemini image expansion returned %d queries", len(queries))
|
||||||
|
g.debug("gemini:image_expand_invalid_count", map[string]any{"query": trimmed, "queries": queries, "error": err.Error()})
|
||||||
|
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
|
||||||
|
return fallback, err
|
||||||
|
}
|
||||||
|
|
||||||
|
g.setCachedExpansion(cacheKey, queries, 15*time.Minute)
|
||||||
|
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": queries})
|
||||||
|
return queries, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) {
|
func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) {
|
||||||
trimmed := strings.TrimSpace(text)
|
trimmed := strings.TrimSpace(text)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -727,6 +806,34 @@ func truncateForError(text string, limit int) string {
|
|||||||
return trimmed[:limit] + "..."
|
return trimmed[:limit] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseLooseImageExpansionLines(text string) []string {
|
||||||
|
candidates := make([]string, 0, 8)
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "- ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "* ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "1. ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "2. ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "3. ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "4. ")
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "5. ")
|
||||||
|
trimmed = strings.TrimSpace(strings.Trim(trimmed, "\"'`"))
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "json") || strings.HasPrefix(lower, "output") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, trimmed)
|
||||||
|
}
|
||||||
|
queries := normalizeImageExpansionQueries(candidates)
|
||||||
|
if len(queries) < 5 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return queries[:5]
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeKoreanReason(reason string) string {
|
func normalizeKoreanReason(reason string) string {
|
||||||
trimmed := strings.TrimSpace(reason)
|
trimmed := strings.TrimSpace(reason)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
service.TranslateEndpoint = server.URL
|
service.TranslateEndpoint = server.URL
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
service.TranslateEndpoint = server.URL
|
service.TranslateEndpoint = server.URL
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
service.TranslateEndpoint = server.URL
|
service.TranslateEndpoint = server.URL
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
service := NewGeminiService("dummy-key")
|
service := NewGeminiService("dummy-key", "")
|
||||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
service.GenerateEndpoint = server.URL
|
service.GenerateEndpoint = server.URL
|
||||||
|
|
||||||
@@ -99,6 +99,72 @@ func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExpandImageQueriesReturnsExactlyFiveEnglishQueries(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"funny cat gif\",\"cute cat reaction\",\"cat meme gif\",\"animated cat sticker\"]}"}]}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||||
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
service.GenerateEndpoint = server.URL
|
||||||
|
|
||||||
|
queries, err := service.ExpandImageQueries("웃긴 고양이")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected image query expansion to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if len(queries) != 5 {
|
||||||
|
t.Fatalf("expected exactly 5 queries, got %#v", queries)
|
||||||
|
}
|
||||||
|
if queries[0] != "funny cat" || queries[4] != "animated cat sticker" {
|
||||||
|
t.Fatalf("unexpected image queries: %#v", queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandImageQueriesFallsBackWhenGeminiFails(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||||
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
service.GenerateEndpoint = server.URL
|
||||||
|
service.TranslateEndpoint = server.URL
|
||||||
|
|
||||||
|
queries, err := service.ExpandImageQueries("happy dog")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected fallback warning error when gemini expansion fails")
|
||||||
|
}
|
||||||
|
if len(queries) != 5 {
|
||||||
|
t.Fatalf("expected fallback to still provide 5 queries, got %#v", queries)
|
||||||
|
}
|
||||||
|
if queries[0] != "happy dog" {
|
||||||
|
t.Fatalf("expected original query to be preserved in fallback, got %#v", queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandImageQueriesAcceptsLoosePlainTextList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"Here is the JSON requested\n1. cute cat\n2. cute cat gif\n3. cat reaction gif\n4. cat meme gif\n5. animated cat sticker"}]}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||||
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
service.GenerateEndpoint = server.URL
|
||||||
|
|
||||||
|
queries, err := service.ExpandImageQueries("고양이")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected loose plain-text list to be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
if len(queries) != 5 || queries[0] != "cute cat" || queries[4] != "animated cat sticker" {
|
||||||
|
t.Fatalf("unexpected loose parsed queries: %#v", queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
|
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
|
||||||
ranked := []SearchResult{
|
ranked := []SearchResult{
|
||||||
{Link: "https://a.example"},
|
{Link: "https://a.example"},
|
||||||
@@ -129,7 +195,7 @@ func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute)
|
service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute)
|
||||||
|
|
||||||
data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg")
|
data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg")
|
||||||
@@ -142,7 +208,7 @@ func TestGeminiVisualCacheRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute)
|
service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute)
|
||||||
|
|
||||||
value, ok := service.getCachedTranslation("비 오는 도시")
|
value, ok := service.getCachedTranslation("비 오는 도시")
|
||||||
@@ -155,7 +221,7 @@ func TestGeminiTranslationCacheRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiExpansionCacheRoundTrip(t *testing.T) {
|
func TestGeminiExpansionCacheRoundTrip(t *testing.T) {
|
||||||
service := NewGeminiService("")
|
service := NewGeminiService("", "")
|
||||||
service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute)
|
service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute)
|
||||||
|
|
||||||
value, ok := service.getCachedExpansion("city rain")
|
value, ok := service.getCachedExpansion("city rain")
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultGiphyAPIBaseURL = "https://api.giphy.com"
|
||||||
|
defaultGiphyMaxResults = 100
|
||||||
|
giphyBatchSize = 20
|
||||||
|
giphyDownloadSizeLimit = 50 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type GiphyConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
APIKey string
|
||||||
|
MaxResults int
|
||||||
|
Rating string
|
||||||
|
Lang string
|
||||||
|
DownloadDir string
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphyService struct {
|
||||||
|
Config GiphyConfig
|
||||||
|
Client *http.Client
|
||||||
|
Gemini *GeminiService
|
||||||
|
Debug func(message string, data any)
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphyResult struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ProviderID string `json:"providerId"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SearchQuery string `json:"searchQuery"`
|
||||||
|
OriginalQuery string `json:"originalQuery,omitempty"`
|
||||||
|
PreviewURL string `json:"previewUrl"`
|
||||||
|
PreviewStillURL string `json:"previewStillUrl"`
|
||||||
|
FullURL string `json:"fullUrl"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
SourcePageURL string `json:"sourcePageUrl"`
|
||||||
|
OpenURL string `json:"openUrl,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
ActionLabel string `json:"actionLabel,omitempty"`
|
||||||
|
ActionType string `json:"actionType,omitempty"`
|
||||||
|
SecondaryActionLabel string `json:"secondaryActionLabel,omitempty"`
|
||||||
|
PreviewBlockedReason string `json:"previewBlockedReason,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphySearchResponse struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
OriginalQuery string `json:"originalQuery"`
|
||||||
|
ExpandedQueries []string `json:"expandedQueries"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []GiphyResult `json:"items"`
|
||||||
|
Warning string `json:"warning,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphyDownloadRequest struct {
|
||||||
|
ProviderID string `json:"providerId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
OriginalQuery string `json:"originalQuery"`
|
||||||
|
SelectedExpansionQuery string `json:"selectedExpansionQuery"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphyDownloadResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
FileName string `json:"fileName,omitempty"`
|
||||||
|
SavedPath string `json:"savedPath,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type giphySearchAPIResponse struct {
|
||||||
|
Data []giphyAPIItem `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type giphyAPIItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Rating string `json:"rating"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Images struct {
|
||||||
|
Original giphyRendition `json:"original"`
|
||||||
|
OriginalStill giphyRendition `json:"original_still"`
|
||||||
|
FixedWidth giphyRendition `json:"fixed_width"`
|
||||||
|
FixedWidthStill giphyRendition `json:"fixed_width_still"`
|
||||||
|
FixedWidthDownsample giphyRendition `json:"fixed_width_downsampled"`
|
||||||
|
FixedHeight giphyRendition `json:"fixed_height"`
|
||||||
|
PreviewGIF giphyRendition `json:"preview_gif"`
|
||||||
|
Downsized giphyRendition `json:"downsized"`
|
||||||
|
DownsizedLarge giphyRendition `json:"downsized_large"`
|
||||||
|
} `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type giphyRendition struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
MP4 string `json:"mp4"`
|
||||||
|
WebP string `json:"webp"`
|
||||||
|
Width string `json:"width"`
|
||||||
|
Height string `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGiphyService(config GiphyConfig, gemini *GeminiService) *GiphyService {
|
||||||
|
if config.MaxResults <= 0 {
|
||||||
|
config.MaxResults = defaultGiphyMaxResults
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.Rating) == "" {
|
||||||
|
config.Rating = "g"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.Lang) == "" {
|
||||||
|
config.Lang = "en"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.BaseURL) == "" {
|
||||||
|
config.BaseURL = defaultGiphyAPIBaseURL
|
||||||
|
}
|
||||||
|
return &GiphyService{
|
||||||
|
Config: config,
|
||||||
|
BaseURL: strings.TrimRight(config.BaseURL, "/"),
|
||||||
|
Client: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
Gemini: gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiphyService) SearchImages(query string, requestedMax int) (GiphySearchResponse, error) {
|
||||||
|
response := GiphySearchResponse{
|
||||||
|
Provider: "giphy",
|
||||||
|
OriginalQuery: strings.TrimSpace(query),
|
||||||
|
}
|
||||||
|
if !s.Config.Enabled {
|
||||||
|
return response, fmt.Errorf("giphy is disabled")
|
||||||
|
}
|
||||||
|
if response.OriginalQuery == "" {
|
||||||
|
return response, fmt.Errorf("query is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.Config.APIKey) == "" {
|
||||||
|
return response, fmt.Errorf("giphy api key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := s.Config.MaxResults
|
||||||
|
if requestedMax > 0 {
|
||||||
|
target = minInt(requestedMax, s.Config.MaxResults)
|
||||||
|
}
|
||||||
|
if target <= 0 {
|
||||||
|
target = minInt(defaultGiphyMaxResults, s.Config.MaxResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedQueries, expansionErr := s.expandQueries(response.OriginalQuery)
|
||||||
|
response.ExpandedQueries = expandedQueries
|
||||||
|
if expansionErr != nil {
|
||||||
|
s.debug("giphy:query_expansion_fallback", map[string]any{
|
||||||
|
"query": response.OriginalQuery,
|
||||||
|
"queries": expandedQueries,
|
||||||
|
"error": expansionErr.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
s.debug("giphy:query_expansion", map[string]any{
|
||||||
|
"query": response.OriginalQuery,
|
||||||
|
"queries": expandedQueries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryState struct {
|
||||||
|
query string
|
||||||
|
offset int
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
states := make([]queryState, 0, len(expandedQueries))
|
||||||
|
for _, item := range expandedQueries {
|
||||||
|
states = append(states, queryState{query: item, enabled: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]GiphyResult, 0, target)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var allErrs []string
|
||||||
|
var successfulCalls int
|
||||||
|
|
||||||
|
fetchRound := func(limit int) bool {
|
||||||
|
progress := false
|
||||||
|
for idx := range states {
|
||||||
|
if !states[idx].enabled || len(items) >= target {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batchLimit := minInt(limit, target-len(items))
|
||||||
|
found, err := s.searchQuery(states[idx].query, batchLimit, states[idx].offset, response.OriginalQuery)
|
||||||
|
if err != nil {
|
||||||
|
allErrs = append(allErrs, err.Error())
|
||||||
|
s.debug("giphy:query_error", map[string]any{
|
||||||
|
"query": states[idx].query,
|
||||||
|
"offset": states[idx].offset,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successfulCalls++
|
||||||
|
s.debug("giphy:query_results", map[string]any{
|
||||||
|
"query": states[idx].query,
|
||||||
|
"offset": states[idx].offset,
|
||||||
|
"count": len(found),
|
||||||
|
})
|
||||||
|
if len(found) == 0 {
|
||||||
|
states[idx].enabled = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
states[idx].offset += len(found)
|
||||||
|
before := len(items)
|
||||||
|
for _, item := range found {
|
||||||
|
if len(items) >= target {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if mergeUniqueGiphyResult(&items, item, seen) {
|
||||||
|
progress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) < batchLimit && len(items) == before {
|
||||||
|
states[idx].enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := fetchRound(minInt(giphyBatchSize, target))
|
||||||
|
for round := 0; len(items) < target && progress && round < 3; round++ {
|
||||||
|
progress = fetchRound(minInt(giphyBatchSize, target-len(items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Items = items
|
||||||
|
response.Total = len(items)
|
||||||
|
s.debug("giphy:search_complete", map[string]any{
|
||||||
|
"query": response.OriginalQuery,
|
||||||
|
"expanded": response.ExpandedQueries,
|
||||||
|
"total": response.Total,
|
||||||
|
"target": target,
|
||||||
|
"warning": response.Warning,
|
||||||
|
"successCalls": successfulCalls,
|
||||||
|
})
|
||||||
|
|
||||||
|
if response.Total == 0 && len(allErrs) > 0 {
|
||||||
|
return response, fmt.Errorf("giphy search failed: %s", strings.Join(uniqueStrings(allErrs, 3), "; "))
|
||||||
|
}
|
||||||
|
if len(allErrs) > 0 && response.Warning == "" {
|
||||||
|
response.Warning = "Some GIPHY requests failed, showing partial results."
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiphyService) DownloadMedia(req GiphyDownloadRequest) (GiphyDownloadResponse, error) {
|
||||||
|
if !s.Config.Enabled {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "GIPHY_DISABLED", Message: "GIPHY is disabled"}, fmt.Errorf("giphy is disabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ProviderID) == "" || strings.TrimSpace(req.DownloadURL) == "" {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "INVALID_REQUEST", Message: "providerId and downloadUrl are required"}, fmt.Errorf("providerId and downloadUrl are required")
|
||||||
|
}
|
||||||
|
if !isAllowedGiphyDownloadURL(req.DownloadURL) {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "INVALID_DOWNLOAD_URL", Message: "Only approved GIPHY media URLs are allowed"}, fmt.Errorf("download url is not on an approved giphy host")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.Config.DownloadDir, 0o755); err != nil {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_DIR_FAILED", Message: "Failed to prepare GIPHY download directory"}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(http.MethodGet, req.DownloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to build GIPHY download request"}, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("User-Agent", "AI-Media-Hub/1.0")
|
||||||
|
resp, err := s.Client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to download media from GIPHY"}, fmt.Errorf("giphy download returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.ContentLength > giphyDownloadSizeLimit {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download too large: %d", resp.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||||
|
extension := determineGiphyExtension(req.DownloadURL, contentType)
|
||||||
|
fileName := buildGiphyFilename(req.ProviderID, req.Title, extension, time.Now())
|
||||||
|
targetPath := filepath.Join(s.Config.DownloadDir, fileName)
|
||||||
|
cleanTargetPath := filepath.Clean(targetPath)
|
||||||
|
cleanBaseDir := filepath.Clean(s.Config.DownloadDir)
|
||||||
|
if !strings.HasPrefix(cleanTargetPath, cleanBaseDir) {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "INVALID_PATH", Message: "Resolved download path is invalid"}, fmt.Errorf("resolved giphy download path escaped base directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, giphyDownloadSizeLimit+1))
|
||||||
|
if err != nil {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to read media from GIPHY"}, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > giphyDownloadSizeLimit {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_TOO_LARGE", Message: "GIPHY media exceeds the configured size limit"}, fmt.Errorf("giphy download exceeded size limit during read")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cleanTargetPath, data, 0o644); err != nil {
|
||||||
|
return GiphyDownloadResponse{OK: false, Error: "DOWNLOAD_FAILED", Message: "Failed to save media from GIPHY"}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.debug("giphy:download_success", map[string]any{
|
||||||
|
"providerId": req.ProviderID,
|
||||||
|
"fileName": fileName,
|
||||||
|
"savedPath": cleanTargetPath,
|
||||||
|
})
|
||||||
|
return GiphyDownloadResponse{
|
||||||
|
OK: true,
|
||||||
|
FileName: fileName,
|
||||||
|
SavedPath: cleanTargetPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiphyService) expandQueries(query string) ([]string, error) {
|
||||||
|
if s.Gemini == nil {
|
||||||
|
return buildFallbackImageQueries(query, strings.TrimSpace(query)), fmt.Errorf("gemini service is not configured")
|
||||||
|
}
|
||||||
|
return s.Gemini.ExpandImageQueries(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiphyService) searchQuery(query string, limit, offset int, originalQuery string) ([]GiphyResult, error) {
|
||||||
|
endpoint, err := neturl.Parse(s.BaseURL + "/v1/gifs/search")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := endpoint.Query()
|
||||||
|
params.Set("api_key", s.Config.APIKey)
|
||||||
|
params.Set("q", query)
|
||||||
|
params.Set("limit", strconv.Itoa(limit))
|
||||||
|
params.Set("offset", strconv.Itoa(max(offset, 0)))
|
||||||
|
params.Set("rating", s.Config.Rating)
|
||||||
|
params.Set("lang", s.Config.Lang)
|
||||||
|
endpoint.RawQuery = params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("giphy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload giphySearchAPIResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]GiphyResult, 0, len(payload.Data))
|
||||||
|
for _, item := range payload.Data {
|
||||||
|
mapped := mapGiphyItem(item, query, originalQuery)
|
||||||
|
if mapped.ProviderID == "" || mapped.FullURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, mapped)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapGiphyItem(item giphyAPIItem, searchQuery, originalQuery string) GiphyResult {
|
||||||
|
previewURL := firstNonEmpty(
|
||||||
|
item.Images.FixedWidthDownsample.URL,
|
||||||
|
item.Images.FixedWidth.URL,
|
||||||
|
item.Images.FixedHeight.URL,
|
||||||
|
item.Images.PreviewGIF.URL,
|
||||||
|
item.Images.Downsized.URL,
|
||||||
|
item.Images.Original.URL,
|
||||||
|
)
|
||||||
|
stillURL := firstNonEmpty(
|
||||||
|
item.Images.FixedWidthStill.URL,
|
||||||
|
item.Images.OriginalStill.URL,
|
||||||
|
item.Images.FixedWidth.URL,
|
||||||
|
item.Images.Downsized.URL,
|
||||||
|
item.Images.Original.URL,
|
||||||
|
)
|
||||||
|
fullURL := firstNonEmpty(
|
||||||
|
item.Images.Original.URL,
|
||||||
|
item.Images.DownsizedLarge.URL,
|
||||||
|
item.Images.Downsized.URL,
|
||||||
|
item.Images.FixedWidth.URL,
|
||||||
|
)
|
||||||
|
downloadURL := firstNonEmpty(
|
||||||
|
item.Images.Original.URL,
|
||||||
|
item.Images.DownsizedLarge.URL,
|
||||||
|
item.Images.FixedWidth.URL,
|
||||||
|
item.Images.Original.MP4,
|
||||||
|
)
|
||||||
|
width := atoiOrZero(firstNonEmpty(item.Images.Original.Width, item.Images.FixedWidth.Width, item.Images.DownsizedLarge.Width))
|
||||||
|
height := atoiOrZero(firstNonEmpty(item.Images.Original.Height, item.Images.FixedWidth.Height, item.Images.DownsizedLarge.Height))
|
||||||
|
title := strings.TrimSpace(item.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.ReplaceAll(strings.TrimSpace(item.Slug), "-", " ")
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "Untitled GIPHY"
|
||||||
|
}
|
||||||
|
return GiphyResult{
|
||||||
|
Provider: "giphy",
|
||||||
|
ProviderID: strings.TrimSpace(item.ID),
|
||||||
|
Link: strings.TrimSpace(item.URL),
|
||||||
|
Title: title,
|
||||||
|
SearchQuery: searchQuery,
|
||||||
|
OriginalQuery: originalQuery,
|
||||||
|
PreviewURL: previewURL,
|
||||||
|
PreviewStillURL: stillURL,
|
||||||
|
FullURL: fullURL,
|
||||||
|
DownloadURL: downloadURL,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Rating: strings.TrimSpace(item.Rating),
|
||||||
|
SourcePageURL: strings.TrimSpace(item.URL),
|
||||||
|
OpenURL: strings.TrimSpace(item.URL),
|
||||||
|
Source: "GIPHY",
|
||||||
|
ActionLabel: "Download",
|
||||||
|
ActionType: "giphy_download",
|
||||||
|
SecondaryActionLabel: "Open Original",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeUniqueGiphyResult(items *[]GiphyResult, candidate GiphyResult, seen map[string]bool) bool {
|
||||||
|
for _, key := range giphyDedupKeys(candidate) {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[key] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range giphyDedupKeys(candidate) {
|
||||||
|
if key != "" {
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*items = append(*items, candidate)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func giphyDedupKeys(item GiphyResult) []string {
|
||||||
|
keys := []string{}
|
||||||
|
if item.ProviderID != "" {
|
||||||
|
keys = append(keys, "id:"+strings.ToLower(item.ProviderID))
|
||||||
|
}
|
||||||
|
if item.FullURL != "" {
|
||||||
|
keys = append(keys, "full:"+strings.ToLower(strings.TrimSpace(item.FullURL)))
|
||||||
|
}
|
||||||
|
if item.SourcePageURL != "" {
|
||||||
|
keys = append(keys, "source:"+strings.ToLower(strings.TrimSpace(item.SourcePageURL)))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFallbackImageQueries(originalQuery, englishBase string) []string {
|
||||||
|
base := strings.TrimSpace(englishBase)
|
||||||
|
if base == "" {
|
||||||
|
base = strings.TrimSpace(originalQuery)
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
base,
|
||||||
|
base + " gif",
|
||||||
|
base + " reaction gif",
|
||||||
|
base + " meme gif",
|
||||||
|
base + " animated gif",
|
||||||
|
base + " reaction image",
|
||||||
|
base + " sticker",
|
||||||
|
}
|
||||||
|
queries := normalizeImageExpansionQueries(candidates)
|
||||||
|
if len(queries) > 5 {
|
||||||
|
return queries[:5]
|
||||||
|
}
|
||||||
|
for len(queries) < 5 {
|
||||||
|
queries = append(queries, fmt.Sprintf("%s gif %d", base, len(queries)+1))
|
||||||
|
}
|
||||||
|
return queries[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImageExpansionQueries(items []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
queries := make([]string, 0, 5)
|
||||||
|
for _, item := range items {
|
||||||
|
normalized := sanitizePlainEnglishLine(item)
|
||||||
|
normalized = strings.Join(strings.Fields(normalized), " ")
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !looksMostlyASCII(normalized) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(normalized)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
queries = append(queries, normalized)
|
||||||
|
}
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedGiphyDownloadURL(rawURL string) bool {
|
||||||
|
parsed, err := neturl.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return host == "giphy.com" || strings.HasSuffix(host, ".giphy.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGiphyFilename(providerID, title, extension string, now time.Time) string {
|
||||||
|
slug := sanitizeGiphyFilenameComponent(title)
|
||||||
|
if slug == "" {
|
||||||
|
slug = "untitled"
|
||||||
|
}
|
||||||
|
providerID = sanitizeGiphyFilenameComponent(providerID)
|
||||||
|
if providerID == "" {
|
||||||
|
providerID = "unknown"
|
||||||
|
}
|
||||||
|
ext := extension
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("giphy_%s_%s_%s%s", providerID, slug, now.Format("20060102_150405"), ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeGiphyFilenameComponent(value string) string {
|
||||||
|
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
normalized = re.ReplaceAllString(normalized, "-")
|
||||||
|
return strings.Trim(normalized, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func determineGiphyExtension(rawURL, contentType string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif"
|
||||||
|
case "video/mp4":
|
||||||
|
return ".mp4"
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg"
|
||||||
|
}
|
||||||
|
parsed, err := neturl.Parse(rawURL)
|
||||||
|
if err == nil {
|
||||||
|
ext := strings.ToLower(path.Ext(parsed.Path))
|
||||||
|
switch ext {
|
||||||
|
case ".gif", ".mp4", ".webp", ".png", ".jpg", ".jpeg":
|
||||||
|
if ext == ".jpeg" {
|
||||||
|
return ".jpg"
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ".gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiOrZero(value string) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(items []string, limit int) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
unique := make([]string, 0, minInt(len(items), limit))
|
||||||
|
for _, item := range items {
|
||||||
|
trimmed := strings.TrimSpace(item)
|
||||||
|
if trimmed == "" || seen[trimmed] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = true
|
||||||
|
unique = append(unique, trimmed)
|
||||||
|
if len(unique) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GiphyService) debug(message string, data any) {
|
||||||
|
if s != nil && s.Debug != nil {
|
||||||
|
s.Debug(message, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGiphyServiceAppliesDefaults(t *testing.T) {
|
||||||
|
service := NewGiphyService(GiphyConfig{Enabled: true}, nil)
|
||||||
|
if service.Config.MaxResults != 100 {
|
||||||
|
t.Fatalf("expected default max results 100, got %d", service.Config.MaxResults)
|
||||||
|
}
|
||||||
|
if service.Config.Rating != "g" || service.Config.Lang != "en" {
|
||||||
|
t.Fatalf("unexpected defaults: %#v", service.Config)
|
||||||
|
}
|
||||||
|
if service.BaseURL != defaultGiphyAPIBaseURL {
|
||||||
|
t.Fatalf("expected default base url %q, got %q", defaultGiphyAPIBaseURL, service.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedGiphyDownloadURL(t *testing.T) {
|
||||||
|
if !isAllowedGiphyDownloadURL("https://media2.giphy.com/media/test/giphy.gif") {
|
||||||
|
t.Fatal("expected media.giphy.com host to be allowed")
|
||||||
|
}
|
||||||
|
if isAllowedGiphyDownloadURL("https://example.com/file.gif") {
|
||||||
|
t.Fatal("expected non-giphy host to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildGiphyFilenameSanitizesInput(t *testing.T) {
|
||||||
|
got := buildGiphyFilename("ABC123", "Funny Cat!!!", ".gif", time.Date(2026, 3, 24, 15, 32, 12, 0, time.UTC))
|
||||||
|
want := "giphy_abc123_funny-cat_20260324_153212.gif"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGiphySearchAggregatesDedupesAndCapsAt100(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"{\"queries\":[\"funny cat\",\"happy cat gif\",\"cat reaction\",\"cat meme\",\"animated cat sticker\"]}"}]}}]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
limit := atoiOrZero(r.URL.Query().Get("limit"))
|
||||||
|
offset := atoiOrZero(r.URL.Query().Get("offset"))
|
||||||
|
data := make([]map[string]any, 0, limit)
|
||||||
|
for idx := 0; idx < limit; idx++ {
|
||||||
|
id := fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx)
|
||||||
|
if idx == 0 {
|
||||||
|
id = fmt.Sprintf("shared-%d", offset)
|
||||||
|
}
|
||||||
|
data = append(data, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"title": fmt.Sprintf("%s %d", query, offset+idx),
|
||||||
|
"slug": fmt.Sprintf("%s-%d", strings.ReplaceAll(query, " ", "-"), offset+idx),
|
||||||
|
"rating": "g",
|
||||||
|
"url": "https://giphy.com/gifs/" + id,
|
||||||
|
"images": map[string]any{
|
||||||
|
"original": map[string]any{
|
||||||
|
"url": fmt.Sprintf("https://media.giphy.com/media/%s/giphy.gif", id),
|
||||||
|
"width": "480",
|
||||||
|
"height": "270",
|
||||||
|
},
|
||||||
|
"fixed_width": map[string]any{
|
||||||
|
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w.gif", id),
|
||||||
|
"width": "200",
|
||||||
|
"height": "113",
|
||||||
|
},
|
||||||
|
"fixed_width_still": map[string]any{
|
||||||
|
"url": fmt.Sprintf("https://media.giphy.com/media/%s/200w_s.gif", id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
gemini := NewGeminiService("dummy-key", "gemini-2.5-flash")
|
||||||
|
gemini.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
gemini.GenerateEndpoint = server.URL + "/generate"
|
||||||
|
|
||||||
|
service := NewGiphyService(GiphyConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIKey: "test-key",
|
||||||
|
MaxResults: 100,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
}, gemini)
|
||||||
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
|
||||||
|
resp, err := service.SearchImages("웃긴 고양이", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected giphy search to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.ExpandedQueries) != 5 {
|
||||||
|
t.Fatalf("expected 5 expanded queries, got %#v", resp.ExpandedQueries)
|
||||||
|
}
|
||||||
|
if resp.Total != 100 || len(resp.Items) != 100 {
|
||||||
|
t.Fatalf("expected capped 100 unique items, got total=%d len=%d", resp.Total, len(resp.Items))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
if seen[item.ProviderID] {
|
||||||
|
t.Fatalf("found duplicate providerId %q in aggregated results", item.ProviderID)
|
||||||
|
}
|
||||||
|
seen[item.ProviderID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadMediaHappyPath(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/gif")
|
||||||
|
_, _ = w.Write([]byte("GIF89a"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
serverURL, err := url.Parse(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse server url: %v", err)
|
||||||
|
}
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
service := NewGiphyService(GiphyConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIKey: "test-key",
|
||||||
|
DownloadDir: tempDir,
|
||||||
|
}, nil)
|
||||||
|
service.Client = &http.Client{
|
||||||
|
Timeout: 2 * time.Second,
|
||||||
|
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
clone := req.Clone(req.Context())
|
||||||
|
if strings.HasSuffix(clone.URL.Host, "giphy.com") {
|
||||||
|
clone.URL.Scheme = serverURL.Scheme
|
||||||
|
clone.URL.Host = serverURL.Host
|
||||||
|
clone.Host = serverURL.Host
|
||||||
|
}
|
||||||
|
return http.DefaultTransport.RoundTrip(clone)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.DownloadMedia(GiphyDownloadRequest{
|
||||||
|
ProviderID: "abc123",
|
||||||
|
Title: "Funny Cat",
|
||||||
|
DownloadURL: "https://media.giphy.com/media/abc123/giphy.gif",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected download to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if !resp.OK {
|
||||||
|
t.Fatalf("expected ok response, got %#v", resp)
|
||||||
|
}
|
||||||
|
if filepath.Ext(resp.FileName) != ".gif" {
|
||||||
|
t.Fatalf("expected gif extension, got %q", resp.FileName)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(resp.SavedPath); err != nil {
|
||||||
|
t.Fatalf("expected saved file at %q: %v", resp.SavedPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+120
-72
@@ -8,10 +8,9 @@ const queryVariants = document.getElementById("queryVariants");
|
|||||||
const searchModeTitle = document.getElementById("searchModeTitle");
|
const searchModeTitle = document.getElementById("searchModeTitle");
|
||||||
const searchModeHint = document.getElementById("searchModeHint");
|
const searchModeHint = document.getElementById("searchModeHint");
|
||||||
const searchSubmitButton = document.getElementById("searchSubmitButton");
|
const searchSubmitButton = document.getElementById("searchSubmitButton");
|
||||||
|
const searchResultsViewport = document.getElementById("searchResultsViewport");
|
||||||
const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]"));
|
const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]"));
|
||||||
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
|
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
|
||||||
const imageSearchSandbox = document.getElementById("imageSearchSandbox");
|
|
||||||
const imagePromptChips = Array.from(document.querySelectorAll("[data-image-prompt]"));
|
|
||||||
const dropzone = document.getElementById("dropzone");
|
const dropzone = document.getElementById("dropzone");
|
||||||
const fileInput = document.getElementById("fileInput");
|
const fileInput = document.getElementById("fileInput");
|
||||||
const uploadResult = document.getElementById("uploadResult");
|
const uploadResult = document.getElementById("uploadResult");
|
||||||
@@ -47,6 +46,8 @@ const debugSummary = document.getElementById("debugSummary");
|
|||||||
const resultModal = document.getElementById("resultModal");
|
const resultModal = document.getElementById("resultModal");
|
||||||
const resultModalTitle = document.getElementById("resultModalTitle");
|
const resultModalTitle = document.getElementById("resultModalTitle");
|
||||||
const resultModalSource = document.getElementById("resultModalSource");
|
const resultModalSource = document.getElementById("resultModalSource");
|
||||||
|
const resultModalReasonLabel = document.getElementById("resultModalReasonLabel");
|
||||||
|
const resultModalSnippetLabel = document.getElementById("resultModalSnippetLabel");
|
||||||
const resultModalSnippet = document.getElementById("resultModalSnippet");
|
const resultModalSnippet = document.getElementById("resultModalSnippet");
|
||||||
const resultModalReason = document.getElementById("resultModalReason");
|
const resultModalReason = document.getElementById("resultModalReason");
|
||||||
const resultModalFrame = document.getElementById("resultModalFrame");
|
const resultModalFrame = document.getElementById("resultModalFrame");
|
||||||
@@ -65,6 +66,8 @@ const resultModalReady = Boolean(
|
|||||||
resultModal &&
|
resultModal &&
|
||||||
resultModalTitle &&
|
resultModalTitle &&
|
||||||
resultModalSource &&
|
resultModalSource &&
|
||||||
|
resultModalReasonLabel &&
|
||||||
|
resultModalSnippetLabel &&
|
||||||
resultModalSnippet &&
|
resultModalSnippet &&
|
||||||
resultModalReason &&
|
resultModalReason &&
|
||||||
resultModalFrame &&
|
resultModalFrame &&
|
||||||
@@ -98,44 +101,6 @@ const resultPreviewInflight = new Map();
|
|||||||
let cardSummaryObserver = null;
|
let cardSummaryObserver = null;
|
||||||
let activeMediaType = "video";
|
let activeMediaType = "video";
|
||||||
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
||||||
const MOCK_IMAGE_RESULTS = [
|
|
||||||
{
|
|
||||||
title: "Neon Crosswalk Portrait",
|
|
||||||
tag: "Test Image",
|
|
||||||
caption: "도심 야간 조명과 보케를 강조한 테스트용 이미지 카드입니다. 향후 이미지 검색 결과 카드 레이아웃 검증에 사용할 수 있습니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/0f172a/f8fafc?text=Neon+Crosswalk",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Editorial Summer Street",
|
|
||||||
tag: "Prototype",
|
|
||||||
caption: "에디토리얼 라이프스타일 톤을 가정한 샘플 썸네일입니다. 카드 비율과 타이포 계층을 보기에 적당합니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/f59e0b/111827?text=Summer+Street",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Minimal Product Tabletop",
|
|
||||||
tag: "Mock Result",
|
|
||||||
caption: "제품 컷 기반 이미지 검색을 상정한 목업 결과입니다. 이미지 중심 레이아웃에서 텍스트 양을 테스트하기 위한 예시입니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/e5e7eb/111827?text=Product+Tabletop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Vintage Fashion Frame",
|
|
||||||
tag: "Image Search",
|
|
||||||
caption: "패션 무드보드나 포스터 레퍼런스 탐색 화면에 어울리도록 구성한 테스트용 결과입니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/7c3aed/f5f3ff?text=Vintage+Fashion",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Botanical Studio Light",
|
|
||||||
tag: "Preview",
|
|
||||||
caption: "정적인 피사체 중심 이미지 검색 UI에서 호버 없이도 충분히 읽히는 카드 밀도를 보기 위한 샘플입니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/14532d/d1fae5?text=Botanical+Studio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Magazine Cover Layout",
|
|
||||||
tag: "Test Asset",
|
|
||||||
caption: "향후 이미지 상세 모달이나 컬렉션 기능을 붙일 때 사용할 수 있는 테스트용 카드 자리입니다.",
|
|
||||||
imageUrl: "https://placehold.co/1200x900/111827/fde68a?text=Magazine+Cover",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function proxiedPreviewURL(src) {
|
function proxiedPreviewURL(src) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
@@ -202,6 +167,9 @@ function setStatus(label, progress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setHidden(element, hidden, visibleDisplayClass = "flex") {
|
function setHidden(element, hidden, visibleDisplayClass = "flex") {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
element.classList.toggle("hidden", hidden);
|
element.classList.toggle("hidden", hidden);
|
||||||
if (visibleDisplayClass) {
|
if (visibleDisplayClass) {
|
||||||
element.classList.toggle(visibleDisplayClass, !hidden);
|
element.classList.toggle(visibleDisplayClass, !hidden);
|
||||||
@@ -337,19 +305,30 @@ function syncMediaTypeButtons() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMockImageResults(queryText = "") {
|
function renderImageEmptyState(message) {
|
||||||
const queryLabel = String(queryText || "").trim() || "test image";
|
searchResults.innerHTML = "";
|
||||||
|
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageResults(items = []) {
|
||||||
searchResults.innerHTML = "";
|
searchResults.innerHTML = "";
|
||||||
searchResults.classList.remove("xl:grid-cols-3");
|
searchResults.classList.remove("xl:grid-cols-3");
|
||||||
searchResults.classList.add("xl:grid-cols-4");
|
searchResults.classList.add("xl:grid-cols-4");
|
||||||
for (const item of MOCK_IMAGE_RESULTS) {
|
if (!items.length) {
|
||||||
|
renderImageEmptyState("GIPHY에서 표시할 이미지/GIF를 찾지 못했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
const node = imageCardTemplate.content.firstElementChild.cloneNode(true);
|
const node = imageCardTemplate.content.firstElementChild.cloneNode(true);
|
||||||
const image = node.querySelector("img");
|
const image = node.querySelector("img");
|
||||||
image.src = item.imageUrl;
|
image.loading = "lazy";
|
||||||
|
image.src = item.previewStillUrl || item.previewUrl || item.fullUrl || PREVIEW_PLACEHOLDER;
|
||||||
image.alt = item.title;
|
image.alt = item.title;
|
||||||
node.querySelector(".image-card-tag").textContent = `${item.tag} / ${queryLabel}`;
|
node.querySelector(".image-card-tag").textContent = `GIPHY / ${item.searchQuery || "query"}`;
|
||||||
node.querySelector(".image-card-title").textContent = item.title;
|
node.querySelector(".image-card-title").textContent = item.title;
|
||||||
node.querySelector(".image-card-caption").textContent = item.caption;
|
node.querySelector(".image-card-caption").textContent = item.title || "Untitled GIPHY result";
|
||||||
|
node.querySelector(".image-card-meta").textContent = `${item.rating || "unrated"} / ${item.width || "?"}x${item.height || "?"}`;
|
||||||
|
node.addEventListener("click", () => openResultModal(item));
|
||||||
searchResults.appendChild(node);
|
searchResults.appendChild(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,21 +336,21 @@ function renderMockImageResults(queryText = "") {
|
|||||||
function applyMediaTypeUI() {
|
function applyMediaTypeUI() {
|
||||||
const isImageMode = activeMediaType === "image";
|
const isImageMode = activeMediaType === "image";
|
||||||
syncMediaTypeButtons();
|
syncMediaTypeButtons();
|
||||||
setHidden(imageSearchSandbox, !isImageMode, "block");
|
|
||||||
setHidden(queryVariants, true, "");
|
setHidden(queryVariants, true, "");
|
||||||
showWarning("");
|
showWarning("");
|
||||||
|
searchResultsViewport.classList.toggle("image-results-scroll", isImageMode);
|
||||||
searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery";
|
searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery";
|
||||||
searchModeHint.textContent = isImageMode
|
searchModeHint.textContent = isImageMode
|
||||||
? "이미지 검색 프로토타입 모드입니다. 현재는 UI 전용 테스트 결과를 표시합니다."
|
? "GIPHY 이미지/GIF 검색 결과를 그대로 보여주는 모드입니다. 최대 100개 결과를 내부 스크롤로 탐색할 수 있습니다."
|
||||||
: "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.";
|
: "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.";
|
||||||
searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요";
|
searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요";
|
||||||
searchSubmitButton.textContent = isImageMode ? "Image Search Test" : "AI Search";
|
searchSubmitButton.textContent = isImageMode ? "Search GIPHY" : "AI Search";
|
||||||
for (const button of platformToggles) {
|
for (const button of platformToggles) {
|
||||||
button.classList.toggle("hidden", isImageMode);
|
button.classList.toggle("hidden", isImageMode);
|
||||||
}
|
}
|
||||||
if (isImageMode) {
|
if (isImageMode) {
|
||||||
setStatus("image prototype mode", 0);
|
setStatus("giphy image mode", 0);
|
||||||
renderMockImageResults(searchQuery.value);
|
renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다.");
|
||||||
} else {
|
} else {
|
||||||
searchResults.classList.add("xl:grid-cols-3");
|
searchResults.classList.add("xl:grid-cols-3");
|
||||||
searchResults.classList.remove("xl:grid-cols-4");
|
searchResults.classList.remove("xl:grid-cols-4");
|
||||||
@@ -579,6 +558,10 @@ function resetResultModalMedia() {
|
|||||||
setHidden(resultModalGooglePanel, true, "flex");
|
setHidden(resultModalGooglePanel, true, "flex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGiphyResult(item) {
|
||||||
|
return String(item?.provider || "").toLowerCase() === "giphy" || String(item?.source || "").toLowerCase() === "giphy";
|
||||||
|
}
|
||||||
|
|
||||||
function showResultModalFrame(src) {
|
function showResultModalFrame(src) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return;
|
return;
|
||||||
@@ -830,21 +813,42 @@ async function openResultModal(item) {
|
|||||||
logEvent("result:modal:error", { message: "result modal is not fully initialized" });
|
logEvent("result:modal:error", { message: "result modal is not fully initialized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const giphyItem = isGiphyResult(item);
|
||||||
activeResultItem = item;
|
activeResultItem = item;
|
||||||
activeResultModalSummaryRequest += 1;
|
activeResultModalSummaryRequest += 1;
|
||||||
const summaryRequestId = activeResultModalSummaryRequest;
|
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 노트가 없습니다.";
|
resultModalReasonLabel.textContent = giphyItem ? "Result Info" : "AI Note";
|
||||||
const originalSummary = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
resultModalSnippetLabel.textContent = giphyItem ? "Source" : "Source Summary";
|
||||||
|
resultModalReason.textContent = giphyItem
|
||||||
|
? [
|
||||||
|
`Rating: ${item.rating || "unrated"}`,
|
||||||
|
`Size: ${item.width || "?"} x ${item.height || "?"}`,
|
||||||
|
`Provider ID: ${item.providerId || "-"}`,
|
||||||
|
].join("\n")
|
||||||
|
: (summarizeReason(item.reason) || "AI 노트가 없습니다.");
|
||||||
|
const originalSummary = giphyItem
|
||||||
|
? [
|
||||||
|
"Powered by GIPHY",
|
||||||
|
item.sourcePageUrl || item.openUrl || item.link || "",
|
||||||
|
`Rating: ${item.rating || "unrated"}`,
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
|
: (item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.");
|
||||||
resultModalSnippet.textContent = originalSummary;
|
resultModalSnippet.textContent = originalSummary;
|
||||||
resultModalOpenExternal.href = item.link || "#";
|
resultModalOpenExternal.href = item.openUrl || item.sourcePageUrl || 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";
|
||||||
const showSecondary = Boolean(item.secondaryActionLabel && item.link);
|
const showSecondary = Boolean(item.secondaryActionLabel && (item.openUrl || item.link));
|
||||||
resultModalSecondaryAction.classList.toggle("hidden", !showSecondary);
|
resultModalSecondaryAction.classList.toggle("hidden", !showSecondary);
|
||||||
resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source";
|
resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source";
|
||||||
resetResultModalMedia();
|
resetResultModalMedia();
|
||||||
|
if (giphyItem) {
|
||||||
|
showResultModalThumbnail(item.fullUrl || item.previewUrl || item.previewStillUrl, item.title || "");
|
||||||
|
showModal(resultModal);
|
||||||
|
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link, provider: item.provider });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const embedURL = buildResultModalEmbedURL(item);
|
const embedURL = buildResultModalEmbedURL(item);
|
||||||
const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview.";
|
const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview.";
|
||||||
let resolvedPreviewURL = item.previewVideoUrl || "";
|
let resolvedPreviewURL = item.previewVideoUrl || "";
|
||||||
@@ -896,9 +900,23 @@ function closeResultViewer() {
|
|||||||
searchForm.addEventListener("submit", async (event) => {
|
searchForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (activeMediaType === "image") {
|
if (activeMediaType === "image") {
|
||||||
renderMockImageResults(searchQuery.value);
|
setStatus("searching GIPHY", 10);
|
||||||
logEvent("image-search:prototype", { query: searchQuery.value, results: MOCK_IMAGE_RESULTS.length });
|
showWarning("");
|
||||||
setStatus("image prototype results ready", 100);
|
try {
|
||||||
|
const data = await api("/api/giphy/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }),
|
||||||
|
});
|
||||||
|
renderImageResults(data.items || []);
|
||||||
|
showWarning("");
|
||||||
|
logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] });
|
||||||
|
setStatus("giphy search complete", 100);
|
||||||
|
} catch (error) {
|
||||||
|
renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다.");
|
||||||
|
showWarning(error.message);
|
||||||
|
setStatus("giphy search failed", 100);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("preparing search", 5);
|
setStatus("preparing search", 5);
|
||||||
@@ -929,16 +947,6 @@ for (const button of mediaTypeToggles) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const chip of imagePromptChips) {
|
|
||||||
chip.addEventListener("click", () => {
|
|
||||||
searchQuery.value = chip.dataset.imagePrompt || "";
|
|
||||||
if (activeMediaType === "image") {
|
|
||||||
renderMockImageResults(searchQuery.value);
|
|
||||||
setStatus("image prompt applied", 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadFile(file) {
|
async function uploadFile(file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
@@ -991,6 +999,35 @@ function closeModal() {
|
|||||||
pendingDownload = null;
|
pendingDownload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadGiphyItem(item) {
|
||||||
|
resultModalDownload.disabled = true;
|
||||||
|
const originalLabel = resultModalDownload.textContent;
|
||||||
|
resultModalDownload.textContent = "Downloading...";
|
||||||
|
try {
|
||||||
|
const data = await api("/api/giphy/download", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
providerId: item.providerId,
|
||||||
|
title: item.title,
|
||||||
|
downloadUrl: item.downloadUrl,
|
||||||
|
originalQuery: item.originalQuery,
|
||||||
|
selectedExpansionQuery: item.searchQuery,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
downloadResult.textContent = `${data.fileName} saved to ${data.savedPath}`;
|
||||||
|
setStatus("giphy download complete", 100);
|
||||||
|
logEvent("giphy:download:completed", { providerId: item.providerId, fileName: data.fileName, savedPath: data.savedPath });
|
||||||
|
} catch (error) {
|
||||||
|
downloadResult.textContent = error.message;
|
||||||
|
setStatus("giphy download failed", 100);
|
||||||
|
logEvent("giphy:download:error", { providerId: item.providerId, message: error.message, data: error.data || null });
|
||||||
|
} finally {
|
||||||
|
resultModalDownload.disabled = false;
|
||||||
|
resultModalDownload.textContent = originalLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dropzone.addEventListener("dragover", (event) => {
|
dropzone.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dropzone.classList.add("border-white/60", "bg-white/[0.08]");
|
dropzone.classList.add("border-white/60", "bg-white/[0.08]");
|
||||||
@@ -1059,10 +1096,14 @@ if (resultModalReady) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
resultModalDownload.addEventListener("click", async () => {
|
resultModalDownload.addEventListener("click", async () => {
|
||||||
if (!activeResultItem?.link) {
|
if (!activeResultItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentItem = activeResultItem;
|
const currentItem = activeResultItem;
|
||||||
|
if (currentItem.actionType === "giphy_download") {
|
||||||
|
await downloadGiphyItem(currentItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentItem.actionType === "download") {
|
if (currentItem.actionType === "download") {
|
||||||
try {
|
try {
|
||||||
closeResultViewer();
|
closeResultViewer();
|
||||||
@@ -1073,13 +1114,13 @@ if (resultModalReady) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.open(currentItem.link, "_blank", "noopener,noreferrer");
|
window.open(currentItem.openUrl || currentItem.sourcePageUrl || currentItem.link, "_blank", "noopener,noreferrer");
|
||||||
});
|
});
|
||||||
resultModalSecondaryAction.addEventListener("click", () => {
|
resultModalSecondaryAction.addEventListener("click", () => {
|
||||||
if (!activeResultItem?.link) {
|
if (!activeResultItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.open(activeResultItem.link, "_blank", "noopener,noreferrer");
|
window.open(activeResultItem.openUrl || activeResultItem.sourcePageUrl || activeResultItem.link, "_blank", "noopener,noreferrer");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
previewModal.addEventListener("click", (event) => {
|
previewModal.addEventListener("click", (event) => {
|
||||||
@@ -1195,6 +1236,13 @@ window.addEventListener("error", (event) => {
|
|||||||
window.addEventListener("unhandledrejection", (event) => {
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
logEvent("window:unhandledrejection", { reason: String(event.reason) });
|
logEvent("window:unhandledrejection", { reason: String(event.reason) });
|
||||||
});
|
});
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
closeResultViewer();
|
||||||
|
});
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
syncPlatformButtons();
|
syncPlatformButtons();
|
||||||
|
|||||||
+7
-30
@@ -52,35 +52,11 @@
|
|||||||
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
|
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
|
||||||
<button id="searchSubmitButton" class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
<button id="searchSubmitButton" class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="imageSearchSandbox" class="mt-4 hidden rounded-3xl border border-white/10 bg-[linear-gradient(135deg,rgba(250,204,21,0.07),rgba(59,130,246,0.08))] p-4">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Image Search Prototype</p>
|
|
||||||
<p class="max-w-2xl text-sm leading-6 text-zinc-300">
|
|
||||||
현재는 UI 전용 테스트 모드입니다. 아래 샘플 스타일 버튼과 테스트 이미지를 사용해 향후 이미지 검색 화면 구성을 미리 볼 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="cinematic city night">Cinematic City</button>
|
|
||||||
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="vintage fashion portrait">Vintage Portrait</button>
|
|
||||||
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="minimal product mockup">Product Mockup</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="imageSearchPreviewStrip" class="mt-4 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
|
||||||
<img src="https://placehold.co/1200x900/0f172a/e2e8f0?text=Test+Image+01" alt="Test image 01" class="aspect-[4/3] w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
|
||||||
<img src="https://placehold.co/1200x900/1f2937/fde68a?text=Test+Image+02" alt="Test image 02" class="aspect-[4/3] w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
|
||||||
<img src="https://placehold.co/1200x900/3f3f46/dbeafe?text=Test+Image+03" alt="Test image 03" class="aspect-[4/3] w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
|
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
|
||||||
<div id="queryVariants" class="hidden"></div>
|
<div id="queryVariants" class="hidden"></div>
|
||||||
<div id="searchResults" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
<div id="searchResultsViewport" class="mt-6">
|
||||||
|
<div id="searchResults" class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div class="grid gap-8">
|
<div class="grid gap-8">
|
||||||
@@ -214,7 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="result-modal-details grid min-h-0 gap-3 px-3 py-3 sm:gap-4 sm:px-4 sm:py-4 lg:grid-cols-[1.5fr_0.8fr]">
|
<div class="result-modal-details grid min-h-0 gap-3 px-3 py-3 sm:gap-4 sm:px-4 sm:py-4 lg:grid-cols-[1.5fr_0.8fr]">
|
||||||
<div class="flex min-h-[180px] min-w-0 flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
<div class="flex min-h-[180px] min-w-0 flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
<p id="resultModalReasonLabel" class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
||||||
<div class="result-panel-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
<div class="result-panel-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
<p id="resultModalReason" class="whitespace-pre-wrap text-xs leading-6 text-zinc-200 sm:text-sm"></p>
|
<p id="resultModalReason" class="whitespace-pre-wrap text-xs leading-6 text-zinc-200 sm:text-sm"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +205,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
<p id="resultModalSnippetLabel" class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
||||||
<div class="result-summary-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
<div class="result-summary-scroll mt-3 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
<p id="resultModalSnippet" class="text-xs leading-6 text-zinc-300 sm:text-sm"></p>
|
<p id="resultModalSnippet" class="text-xs leading-6 text-zinc-300 sm:text-sm"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,10 +244,11 @@
|
|||||||
<div class="space-y-2 p-5">
|
<div class="space-y-2 p-5">
|
||||||
<h3 class="image-card-title line-clamp-2 text-base font-medium text-white"></h3>
|
<h3 class="image-card-title line-clamp-2 text-base font-medium text-white"></h3>
|
||||||
<p class="image-card-caption line-clamp-3 text-sm text-zinc-300"></p>
|
<p class="image-card-caption line-clamp-3 text-sm text-zinc-300"></p>
|
||||||
|
<p class="image-card-meta text-[11px] uppercase tracking-[0.22em] text-zinc-500"></p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/app.js?v=20260324a" defer></script>
|
<script src="/app.js?v=20260324b" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#searchResultsViewport {
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResultsViewport.image-results-scroll {
|
||||||
|
height: min(62dvh, 58rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResultsViewport.image-results-scroll::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResultsViewport.image-results-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-slider__thumb {
|
.dual-slider__thumb {
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
|||||||
+8
-1
@@ -15,9 +15,16 @@
|
|||||||
<Icon>https://raw.githubusercontent.com/selfhst/icons/main/png/google-gemini-light.png</Icon>
|
<Icon>https://raw.githubusercontent.com/selfhst/icons/main/png/google-gemini-light.png</Icon>
|
||||||
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="Dashboard port" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="Dashboard port" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
||||||
<Config Name="Downloads" Target="/app/downloads" Default="/mnt/user/appdata/ai-media-hub/downloads" Mode="rw" Description="Media output directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/downloads</Config>
|
<Config Name="Downloads" Target="/app/downloads" Default="/mnt/user/appdata/ai-media-hub/downloads" Mode="rw" Description="Media output directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/downloads</Config>
|
||||||
|
<Config Name="GIPHY Downloads" Target="/app/downloads/giphy" Default="/mnt/user/appdata/ai-media-hub/giphy" Mode="rw" Description="Directory for downloaded GIPHY images and GIFs" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/giphy</Config>
|
||||||
<Config Name="Database" Target="/app/db" Default="/mnt/user/appdata/ai-media-hub/db" Mode="rw" Description="SQLite database directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/db</Config>
|
<Config Name="Database" Target="/app/db" Default="/mnt/user/appdata/ai-media-hub/db" Mode="rw" Description="SQLite database directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ai-media-hub/db</Config>
|
||||||
<Config Name="SearXNG Base URL" Target="SEARXNG_BASE_URL" Default="http://searxng:8080" Mode="" Description="Base URL for the SearXNG instance" Type="Variable" Display="always" Required="true" Mask="false">http://searxng:8080</Config>
|
<Config Name="SearXNG Base URL" Target="SEARXNG_BASE_URL" Default="http://searxng:8080" Mode="" Description="Base URL for the SearXNG instance" Type="Variable" Display="always" Required="true" Mask="false">http://searxng:8080</Config>
|
||||||
<Config Name="SearXNG Google Video Engine" Target="SEARXNG_GOOGLE_VIDEO_ENGINE" Default="google videos" Mode="" Description="Engine name used for Google video searches" Type="Variable" Display="always" Required="true" Mask="false">google videos</Config>
|
<Config Name="SearXNG Google Video Engine" Target="SEARXNG_GOOGLE_VIDEO_ENGINE" Default="google videos" Mode="" Description="Engine name used for Google video searches" Type="Variable" Display="always" Required="true" Mask="false">google videos</Config>
|
||||||
<Config Name="SearXNG Web Engine" Target="SEARXNG_WEB_ENGINE" Default="google" Mode="" Description="Engine name used for Envato and Artgrid searches" Type="Variable" Display="always" Required="true" Mask="false">google</Config>
|
<Config Name="SearXNG Web Engine" Target="SEARXNG_WEB_ENGINE" Default="google" Mode="" Description="Engine name used for Envato and Artgrid searches" Type="Variable" Display="always" Required="true" Mask="false">google</Config>
|
||||||
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="true" Mask="true"/>
|
<Config Name="GIPHY Enabled" Target="GIPHY_ENABLED" Default="true" Mode="" Description="Enable GIPHY image and GIF search" Type="Variable" Display="always" Required="true" Mask="false">true</Config>
|
||||||
|
<Config Name="GIPHY API Key" Target="GIPHY_API_KEY" Default="" Mode="" Description="GIPHY API key" Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||||
|
<Config Name="GIPHY Max Results" Target="GIPHY_MAX_RESULTS" Default="100" Mode="" Description="Maximum number of aggregated GIPHY results to return" Type="Variable" Display="always" Required="true" Mask="false">100</Config>
|
||||||
|
<Config Name="GIPHY Rating" Target="GIPHY_RATING" Default="g" Mode="" Description="GIPHY content rating filter" Type="Variable" Display="always" Required="true" Mask="false">g</Config>
|
||||||
|
<Config Name="GIPHY Lang" Target="GIPHY_LANG" Default="en" Mode="" Description="Language hint sent to GIPHY search" Type="Variable" Display="always" Required="true" Mask="false">en</Config>
|
||||||
|
<Config Name="Gemini API Key" Target="GEMINI_API_KEY" Default="" Mode="" Description="Gemini API key" Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||||
|
<Config Name="Gemini Model" Target="GEMINI_MODEL" Default="gemini-2.5-flash" Mode="" Description="Gemini model used for multilingual query expansion" Type="Variable" Display="always" Required="true" Mask="false">gemini-2.5-flash</Config>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
Reference in New Issue
Block a user