feat: add Media tab to Research page with media cache viewer

Research page now has Research/Media tab toggle. The Media tab shows
all cached media processing results (image descriptions and PDF OCR)
from media_cache.db with type filtering and expandable detail views.

Backend:
- mediacache.List(): query all entries with optional type filter
- /api/media-cache: list endpoint with ?type= filter
- /api/media-cache/{hash}: full content endpoint (reads MD for PDFs)

Frontend:
- media-cache-page.tsx: card list with type filter (All/Images/PDF)
- Expandable entries: image descriptions inline, PDF with full content
- research.tsx: tab toggle between Research and Media sub-pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-19 22:56:24 +09:00
parent 4c73ae213b
commit f101a977d3
8 changed files with 523 additions and 19 deletions

View file

@ -130,6 +130,47 @@ func (c *Cache) PutEntry(hash, entryType string, entry Entry) error {
return err return err
} }
// ListEntry represents a full row from the media_cache table.
type ListEntry struct {
Hash string
Type string
Result string
FilePath string
Pages int
CreatedAt string
AccessedAt string
}
// List returns all cache entries, optionally filtered by type.
// Pass empty string to list all types. Ordered by accessed_at desc.
func (c *Cache) List(entryType string) ([]ListEntry, error) {
var rows *sql.Rows
var err error
if entryType != "" {
rows, err = c.db.Query(
`SELECT hash, type, result, file_path, pages, created_at, accessed_at
FROM media_cache WHERE type = ? ORDER BY accessed_at DESC`, entryType)
} else {
rows, err = c.db.Query(
`SELECT hash, type, result, file_path, pages, created_at, accessed_at
FROM media_cache ORDER BY accessed_at DESC`)
}
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ListEntry
for rows.Next() {
var e ListEntry
if err := rows.Scan(&e.Hash, &e.Type, &e.Result, &e.FilePath, &e.Pages, &e.CreatedAt, &e.AccessedAt); err != nil {
return entries, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// Prune removes entries not accessed within the given duration. // Prune removes entries not accessed within the given duration.
// Returns the number of entries removed. // Returns the number of entries removed.
func (c *Cache) Prune(ttl time.Duration) (int64, error) { func (c *Cache) Prune(ttl time.Duration) (int64, error) {

View file

@ -0,0 +1,128 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/mediacache"
)
func (h *Handler) registerMediaCacheRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/media-cache", h.handleMediaCache)
mux.HandleFunc("/api/media-cache/", h.handleMediaCacheContent)
}
type mediaCacheEntryJSON struct {
Hash string `json:"hash"`
Type string `json:"type"`
Result string `json:"result"`
FilePath string `json:"file_path,omitempty"`
Pages int `json:"pages,omitempty"`
CreatedAt string `json:"created_at"`
AccessedAt string `json:"accessed_at"`
}
func (h *Handler) openMediaCache() (*mediacache.Cache, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return nil, err
}
ws := cfg.WorkspacePath()
return mediacache.Open(filepath.Join(ws, "media_cache.db"))
}
// handleMediaCache lists all media cache entries.
func (h *Handler) handleMediaCache(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
mc, err := h.openMediaCache()
if err != nil {
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
return
}
defer mc.Close()
typeFilter := r.URL.Query().Get("type")
entries, err := mc.List(typeFilter)
if err != nil {
http.Error(w, `{"error":"failed to list cache entries"}`, http.StatusInternalServerError)
return
}
result := make([]mediaCacheEntryJSON, 0, len(entries))
for _, e := range entries {
result = append(result, mediaCacheEntryJSON{
Hash: e.Hash,
Type: e.Type,
Result: e.Result,
FilePath: e.FilePath,
Pages: e.Pages,
CreatedAt: e.CreatedAt,
AccessedAt: e.AccessedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// handleMediaCacheContent serves the full file content for a PDF OCR entry.
// GET /api/media-cache/{hash}
func (h *Handler) handleMediaCacheContent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
hash := filepath.Base(r.URL.Path)
if hash == "" || hash == "media-cache" {
http.Error(w, `{"error":"hash required"}`, http.StatusBadRequest)
return
}
mc, err := h.openMediaCache()
if err != nil {
http.Error(w, `{"error":"media cache not available"}`, http.StatusServiceUnavailable)
return
}
defer mc.Close()
entry, ok := mc.GetEntry(hash, mediacache.TypePDFOCR)
if !ok {
// Try image_desc
result, ok := mc.Get(hash, mediacache.TypeImageDesc)
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"hash": hash,
"type": mediacache.TypeImageDesc,
"content": result,
})
return
}
// Read the full markdown file
content, err := os.ReadFile(entry.FilePath)
if err != nil {
http.Error(w, `{"error":"file not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"hash": hash,
"type": mediacache.TypePDFOCR,
"content": string(content),
"file_path": entry.FilePath,
"pages": entry.Pages,
})
}

View file

@ -72,6 +72,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Research tasks (proxy to gateway) // Research tasks (proxy to gateway)
h.registerResearchRoutes(mux) h.registerResearchRoutes(mux)
// Media cache (image descriptions, PDF OCR)
h.registerMediaCacheRoutes(mux)
} }
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. // Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.

View file

@ -9,18 +9,18 @@
"@tabler/icons-react": "^3.38.0", "@tabler/icons-react": "^3.38.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.163.3", "@tanstack/react-router": "^1.167.0",
"@tanstack/react-router-devtools": "^1.163.3", "@tanstack/react-router-devtools": "^1.163.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.20",
"i18next": "^25.8.14", "i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.18.0", "jotai": "^2.18.1",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@ -32,7 +32,7 @@
"wrap-ansi": "^10.0.0", "wrap-ansi": "^10.0.0",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0", "@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
@ -40,8 +40,8 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
@ -467,13 +467,13 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "7.3.1" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "7.3.1" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "5.1.35", "tiny-invariant": "1.3.3", "tiny-warning": "1.0.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="], "@tanstack/react-router": ["@tanstack/react-router@1.167.5", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.5", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.163.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.163.3" }, "optionalDependencies": { "@tanstack/router-core": "1.163.3" }, "peerDependencies": { "@tanstack/react-router": "1.163.3", "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.163.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.163.3" }, "optionalDependencies": { "@tanstack/router-core": "1.163.3" }, "peerDependencies": { "@tanstack/react-router": "1.163.3", "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw=="],
@ -553,7 +553,7 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "7.20.5", "react-refresh": "0.18.0" }, "peerDependencies": { "vite": "7.3.1" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@ -673,7 +673,7 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@ -955,7 +955,7 @@
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"jotai": ["jotai@2.18.0", "", { "optionalDependencies": { "@babel/core": "7.29.0", "@babel/template": "7.28.6", "@types/react": "19.2.14", "react": "19.2.4" } }, "sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA=="], "jotai": ["jotai@2.18.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -1249,7 +1249,7 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "7.28.6", "html-parse-stringify": "3.0.1", "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "react-dom": "19.2.4", "typescript": "5.9.3" }, "peerDependencies": { "i18next": "25.8.14", "react": "19.2.4" } }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], "react-i18next": ["react-i18next@16.5.8", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "devlop": "1.1.0", "hast-util-to-jsx-runtime": "2.3.6", "html-url-attributes": "3.0.1", "mdast-util-to-hast": "13.2.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "unified": "11.0.5", "unist-util-visit": "5.1.0", "vfile": "6.0.3" }, "peerDependencies": { "@types/react": "19.2.14", "react": "19.2.4" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "devlop": "1.1.0", "hast-util-to-jsx-runtime": "2.3.6", "html-url-attributes": "3.0.1", "mdast-util-to-hast": "13.2.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "unified": "11.0.5", "unist-util-visit": "5.1.0", "vfile": "6.0.3" }, "peerDependencies": { "@types/react": "19.2.14", "react": "19.2.4" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
@ -1529,6 +1529,12 @@
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.167.5", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "bin": { "intent": "bin/intent.js" } }, "sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ=="],
"@tanstack/router-core/@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"@tanstack/router-plugin/@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "5.1.35", "tiny-invariant": "1.3.3", "tiny-warning": "1.0.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="],
"@ts-morph/common/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "5.0.4" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@ts-morph/common/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "5.0.4" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "5.0.4" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "5.0.4" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
@ -1619,6 +1625,8 @@
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@tanstack/router-plugin/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],

View file

@ -0,0 +1,40 @@
export interface MediaCacheEntry {
hash: string
type: "image_desc" | "pdf_ocr"
result: string
file_path?: string
pages?: number
created_at: string
accessed_at: string
}
export interface MediaCacheContent {
hash: string
type: string
content: string
file_path?: string
pages?: number
}
async function request<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json() as Promise<T>
}
export async function getMediaCacheEntries(
type?: string,
): Promise<MediaCacheEntry[]> {
const params = type ? `?type=${encodeURIComponent(type)}` : ""
return request<MediaCacheEntry[]>(`/api/media-cache${params}`)
}
export async function getMediaCacheContent(
hash: string,
): Promise<MediaCacheContent> {
return request<MediaCacheContent>(
`/api/media-cache/${encodeURIComponent(hash)}`,
)
}

View file

@ -0,0 +1,227 @@
import {
IconFileText,
IconPhoto,
} from "@tabler/icons-react"
import { useQuery } from "@tanstack/react-query"
import * as React from "react"
import {
type MediaCacheContent,
type MediaCacheEntry,
getMediaCacheContent,
getMediaCacheEntries,
} from "@/api/media-cache"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export function MediaCachePage() {
const [typeFilter, setTypeFilter] = React.useState<string>("")
const [expandedHash, setExpandedHash] = React.useState<string | null>(null)
const { data: entries, isLoading, error } = useQuery({
queryKey: ["media-cache", typeFilter],
queryFn: () => getMediaCacheEntries(typeFilter || undefined),
refetchInterval: 30000,
})
return (
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-4">
{/* Type filter */}
<div className="flex gap-2">
<FilterButton
active={typeFilter === ""}
onClick={() => setTypeFilter("")}
>
All
</FilterButton>
<FilterButton
active={typeFilter === "image_desc"}
onClick={() => setTypeFilter("image_desc")}
>
<IconPhoto className="size-3.5" />
Images
</FilterButton>
<FilterButton
active={typeFilter === "pdf_ocr"}
onClick={() => setTypeFilter("pdf_ocr")}
>
<IconFileText className="size-3.5" />
PDF
</FilterButton>
</div>
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">Loading...</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
Failed to load media cache.
</div>
) : !entries?.length ? (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
No cached media yet. Send an image or PDF to get started.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<MediaEntry
key={`${entry.hash}-${entry.type}`}
entry={entry}
expanded={expandedHash === entry.hash}
onToggle={() =>
setExpandedHash(
expandedHash === entry.hash ? null : entry.hash,
)
}
/>
))}
</div>
)}
</div>
</div>
)
}
function FilterButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
variant={active ? "default" : "outline"}
size="sm"
onClick={onClick}
className="gap-1"
>
{children}
</Button>
)
}
function MediaEntry({
entry,
expanded,
onToggle,
}: {
entry: MediaCacheEntry
expanded: boolean
onToggle: () => void
}) {
const isImage = entry.type === "image_desc"
const Icon = isImage ? IconPhoto : IconFileText
const typeLabel = isImage ? "Image" : "PDF"
const typeColor = isImage
? "text-blue-600 bg-blue-50"
: "text-orange-600 bg-orange-50"
const accessed = new Date(entry.accessed_at)
const timeStr = accessed.toLocaleString()
return (
<Card className="gap-0">
<CardHeader
className="cursor-pointer select-none"
onClick={onToggle}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-sm">
<Icon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate font-mono text-xs">{entry.hash}</span>
</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
{entry.result}
</CardDescription>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={cn(
"rounded-md px-2 py-0.5 text-[11px] font-semibold",
typeColor,
)}
>
{typeLabel}
{entry.pages ? ` (${entry.pages}p)` : ""}
</span>
<span className="text-muted-foreground text-[10px]">{timeStr}</span>
</div>
</div>
</CardHeader>
{expanded && (
<CardContent className="border-t pt-3">
<ExpandedContent entry={entry} />
</CardContent>
)}
</Card>
)
}
function ExpandedContent({ entry }: { entry: MediaCacheEntry }) {
const isPDF = entry.type === "pdf_ocr"
const { data, isLoading } = useQuery({
queryKey: ["media-cache-content", entry.hash],
queryFn: () => getMediaCacheContent(entry.hash),
enabled: isPDF, // only fetch full content for PDFs
})
if (!isPDF) {
// Image description: show full result inline
return (
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
Description
</div>
<div className="bg-muted rounded-md p-3 text-sm whitespace-pre-wrap">
{entry.result}
</div>
</div>
)
}
// PDF OCR: show preview + full content on demand
return (
<div className="space-y-3">
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium">Preview</div>
<div className="bg-muted rounded-md p-3 text-sm whitespace-pre-wrap">
{entry.result}
</div>
</div>
{entry.file_path && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<IconFileText className="size-3" />
<span className="font-mono">{entry.file_path}</span>
</div>
)}
{isLoading ? (
<div className="text-muted-foreground py-2 text-sm">
Loading full content...
</div>
) : data?.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium">
Full OCR Content
</div>
<div className="bg-muted max-h-96 overflow-auto rounded-md p-3 text-sm whitespace-pre-wrap">
{data.content}
</div>
</div>
) : null}
</div>
)
}

View file

@ -17,7 +17,6 @@ import {
createResearchTask, createResearchTask,
getResearchTasks, getResearchTasks,
} from "@/api/research" } from "@/api/research"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Card, Card,
@ -83,7 +82,7 @@ export function ResearchPage() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader title={t("navigation.research")}> <div className="flex justify-end px-6 pt-2">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button size="sm"> <Button size="sm">
@ -143,7 +142,7 @@ export function ResearchPage() {
</form> </form>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</PageHeader> </div>
<div className="flex-1 overflow-auto px-6 py-3"> <div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-4"> <div className="w-full max-w-6xl space-y-4">

View file

@ -1,10 +1,19 @@
import {
IconDatabase,
IconFileSearch,
} from "@tabler/icons-react"
import { import {
Outlet, Outlet,
createFileRoute, createFileRoute,
useRouterState, useRouterState,
} from "@tanstack/react-router" } from "@tanstack/react-router"
import * as React from "react"
import { MediaCachePage } from "@/components/research/media-cache-page"
import { ResearchPage } from "@/components/research/research-page" import { ResearchPage } from "@/components/research/research-page"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export const Route = createFileRoute("/research")({ export const Route = createFileRoute("/research")({
component: ResearchRouteLayout, component: ResearchRouteLayout,
@ -14,10 +23,59 @@ function ResearchRouteLayout() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}) })
const [tab, setTab] = React.useState<"research" | "media">("research")
if (pathname === "/research") { // If on a detail sub-route, show Outlet
return <ResearchPage /> if (pathname !== "/research") {
}
return <Outlet /> return <Outlet />
} }
return (
<div className="flex h-full flex-col">
<PageHeader title={tab === "research" ? "Research" : "Media"}>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton
active={tab === "research"}
onClick={() => setTab("research")}
>
<IconFileSearch className="size-3.5" />
Research
</TabButton>
<TabButton
active={tab === "media"}
onClick={() => setTab("media")}
>
<IconDatabase className="size-3.5" />
Media
</TabButton>
</div>
</PageHeader>
{tab === "research" ? <ResearchPage /> : <MediaCachePage />}
</div>
)
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClick}
className={cn(
"gap-1.5 text-xs",
active && "bg-background shadow-sm",
)}
>
{children}
</Button>
)
}