From b7a8e45705394918495cbf23c1b10c4f1b348eda Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:37:51 +0900 Subject: [PATCH] feat: media cache list in Mini App Tools tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cache viewer showing PDF OCR, PDF text extraction, and image description cache entries with type filtering, expand-to-detail, and refresh. Backend: - Add ListMediaCache to DataProvider interface - Add /miniapp/api/cache endpoint with optional ?type= filter - Wire through agentLoopDataProvider → mediacache.List Frontend: - Add CacheSection component to Tools tab - Filter chips (All / PDF OCR / PDF Text / Image) - Expandable entries showing hash, file path, dates, preview Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/agent/loop_info.go | 13 + pkg/gateway/gateway.go | 20 ++ pkg/miniapp/api.go | 10 + .../src/components/tools/cache-section.tsx | 234 ++++++++++++++++++ .../src/components/tools/tools-tab.tsx | 4 +- pkg/miniapp/miniapp.go | 1 + pkg/miniapp/miniapp_test.go | 8 + pkg/miniapp/static/dist/app.js | 226 +++++++++++++++++ pkg/miniapp/types.go | 12 + 9 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 pkg/miniapp/frontend/src/components/tools/cache-section.tsx diff --git a/pkg/agent/loop_info.go b/pkg/agent/loop_info.go index d0278b214..6a1b8c7f2 100644 --- a/pkg/agent/loop_info.go +++ b/pkg/agent/loop_info.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/sipeed/picoclaw/pkg/mediacache" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/stats" @@ -279,3 +280,15 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { return sb.String() } + +// ListMediaCache returns all media cache entries, optionally filtered by type. +func (al *AgentLoop) ListMediaCache(entryType string) []mediacache.ListEntry { + if al.mediaCache == nil { + return nil + } + entries, err := al.mediaCache.List(entryType) + if err != nil { + return nil + } + return entries +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 356f4e4d4..15160e8df 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -888,6 +888,26 @@ func (p *agentLoopDataProvider) GetSystemPrompt() string { return p.loop.GetSystemPrompt() } +func (p *agentLoopDataProvider) ListMediaCache(entryType string) []miniapp.MediaCacheEntry { + raw := p.loop.ListMediaCache(entryType) + if len(raw) == 0 { + return nil + } + entries := make([]miniapp.MediaCacheEntry, len(raw)) + for i, e := range raw { + entries[i] = miniapp.MediaCacheEntry{ + Hash: e.Hash, + Type: e.Type, + Result: e.Result, + FilePath: e.FilePath, + Pages: e.Pages, + CreatedAt: e.CreatedAt, + AccessedAt: e.AccessedAt, + } + } + return entries +} + func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary { if time.Since(p.gitReposCacheAt) < gitCacheTTL { return p.gitReposCache diff --git a/pkg/miniapp/api.go b/pkg/miniapp/api.go index 95df367d7..6854aa9c4 100644 --- a/pkg/miniapp/api.go +++ b/pkg/miniapp/api.go @@ -307,3 +307,13 @@ func writeWorktreeAPIError(w http.ResponseWriter, err error) bool { } // apiDevConsole receives console output from dev preview iframes. + +// apiCache returns a list of media cache entries. +func (h *Handler) apiCache(w http.ResponseWriter, r *http.Request) { + entryType := r.URL.Query().Get("type") + entries := h.provider.ListMediaCache(entryType) + if entries == nil { + entries = []MediaCacheEntry{} + } + writeJSON(w, entries) +} diff --git a/pkg/miniapp/frontend/src/components/tools/cache-section.tsx b/pkg/miniapp/frontend/src/components/tools/cache-section.tsx new file mode 100644 index 000000000..86e1f8b5f --- /dev/null +++ b/pkg/miniapp/frontend/src/components/tools/cache-section.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState, useCallback } from 'preact/hooks'; +import { apiFetch } from '../../hooks/use-api'; + +interface CacheEntry { + hash: string; + type: string; + result: string; + file_path?: string; + pages?: number; + created_at: string; + accessed_at: string; +} + +const TYPE_LABELS: Record = { + pdf_ocr: 'PDF OCR', + pdf_text: 'PDF Text', + image_desc: 'Image', +}; + +const TYPE_COLORS: Record = { + pdf_ocr: { bg: 'rgba(234,179,8,0.15)', text: '#ca8a04' }, + pdf_text: { bg: 'rgba(59,130,246,0.15)', text: '#2563eb' }, + image_desc: { bg: 'rgba(168,85,247,0.15)', text: '#a855f7' }, +}; + +interface CacheSectionProps { + active: boolean; +} + +export function CacheSection({ active }: CacheSectionProps) { + const [entries, setEntries] = useState(null); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(''); + const [expanded, setExpanded] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const url = filter + ? '/miniapp/api/cache?type=' + encodeURIComponent(filter) + : '/miniapp/api/cache'; + const data = await apiFetch(url); + setEntries(data); + } catch { + setEntries(null); + } + setLoading(false); + }, [filter]); + + useEffect(() => { + if (active) load(); + }, [active, filter]); + + const formatDate = (iso: string) => { + try { + const d = new Date(iso); + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString().slice(0, 5); + } catch { + return iso; + } + }; + + return ( +
+
+ + Media Cache + + +
+ +
+ {[ + { label: 'All', value: '' }, + { label: 'PDF OCR', value: 'pdf_ocr' }, + { label: 'PDF Text', value: 'pdf_text' }, + { label: 'Image', value: 'image_desc' }, + ].map((f) => ( + + ))} +
+ + {loading && !entries ? ( +
+ Loading cache... +
+ ) : !entries || entries.length === 0 ? ( +
+ No cached items. +
+ ) : ( + entries.map((e) => { + const tc = TYPE_COLORS[e.type] || TYPE_COLORS.image_desc; + const isExpanded = expanded === e.hash + ':' + e.type; + const preview = + e.result.length > 80 + ? e.result.substring(0, 80) + '...' + : e.result; + + return ( +
+ setExpanded(isExpanded ? null : e.hash + ':' + e.type) + } + > +
+ + {TYPE_LABELS[e.type] || e.type} + + + {preview || '(empty)'} + + {e.pages ? ( + + {e.pages}p + + ) : null} +
+ + {isExpanded && ( +
+
+ Hash:{' '} + {e.hash} +
+ {e.file_path && ( +
+ File:{' '} + + {e.file_path} + +
+ )} +
+ Created:{' '} + {formatDate(e.created_at)} +
+
+ Accessed:{' '} + {formatDate(e.accessed_at)} +
+ {e.result && ( +
+                      {e.result}
+                    
+ )} +
+ )} +
+ ); + }) + )} +
+ ); +} diff --git a/pkg/miniapp/frontend/src/components/tools/tools-tab.tsx b/pkg/miniapp/frontend/src/components/tools/tools-tab.tsx index c7409895e..1bdf9ce48 100644 --- a/pkg/miniapp/frontend/src/components/tools/tools-tab.tsx +++ b/pkg/miniapp/frontend/src/components/tools/tools-tab.tsx @@ -1,10 +1,9 @@ -import { useEffect, useState } from 'preact/hooks'; import type { SSEHook } from '../../hooks/use-sse'; -import { isFresh } from '../../utils'; import { SkillsSection } from './skills-section'; import { CommandsSection } from './commands-section'; import { LogsSection } from './logs-section'; import { ResearchSection } from './research-section'; +import { CacheSection } from './cache-section'; interface ToolsTabProps { active: boolean; @@ -18,6 +17,7 @@ export function ToolsTab({ active, sse }: ToolsTabProps) { + ); } diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 6b6d95077..ff136ed7f 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -107,6 +107,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration)) mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole) mux.HandleFunc("/miniapp/dev/", h.serveDevProxy) + mux.HandleFunc("/miniapp/api/cache", h.requireAuth(h.apiCache)) mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch)) mux.HandleFunc("/miniapp/api/research/focus", h.requireAuth(h.apiResearchFocus)) mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail)) diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 33a32a590..3be2e4958 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -211,6 +211,10 @@ func (m *mockDataProvider) GetSystemPrompt() string { return "mock system prompt" } +func (m *mockDataProvider) ListMediaCache(entryType string) []MediaCacheEntry { + return nil +} + type mockSender struct{} func (m *mockSender) SendCommand(senderID, chatID, command string) {} @@ -563,6 +567,10 @@ func (m *mutatingDataProvider) GetSystemPrompt() string { return "mock system prompt" } +func (m *mutatingDataProvider) ListMediaCache(entryType string) []MediaCacheEntry { + return nil +} + // ── Dev proxy tests ── func TestDevProxy_RegisterAndActivate(t *testing.T) { diff --git a/pkg/miniapp/static/dist/app.js b/pkg/miniapp/static/dist/app.js index c703fdf92..bd6f84b57 100644 --- a/pkg/miniapp/static/dist/app.js +++ b/pkg/miniapp/static/dist/app.js @@ -10496,6 +10496,229 @@ Please report this to https://github.com/markedjs/marked.`, e3) { }, undefined, true, undefined, this); } + // src/components/tools/cache-section.tsx + var TYPE_LABELS = { + pdf_ocr: "PDF OCR", + pdf_text: "PDF Text", + image_desc: "Image" + }; + var TYPE_COLORS = { + pdf_ocr: { bg: "rgba(234,179,8,0.15)", text: "#ca8a04" }, + pdf_text: { bg: "rgba(59,130,246,0.15)", text: "#2563eb" }, + image_desc: { bg: "rgba(168,85,247,0.15)", text: "#a855f7" } + }; + function CacheSection({ active }) { + const [entries, setEntries] = d2(null); + const [loading, setLoading] = d2(false); + const [filter, setFilter] = d2(""); + const [expanded, setExpanded] = d2(null); + const load = q2(async () => { + setLoading(true); + try { + const url = filter ? "/miniapp/api/cache?type=" + encodeURIComponent(filter) : "/miniapp/api/cache"; + const data = await apiFetch(url); + setEntries(data); + } catch { + setEntries(null); + } + setLoading(false); + }, [filter]); + y2(() => { + if (active) + load(); + }, [active, filter]); + const formatDate = (iso) => { + try { + const d3 = new Date(iso); + return d3.toLocaleDateString() + " " + d3.toLocaleTimeString().slice(0, 5); + } catch { + return iso; + } + }; + return /* @__PURE__ */ u5("div", { + class: "card glass", + children: [ + /* @__PURE__ */ u5("div", { + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: "8px" + }, + children: [ + /* @__PURE__ */ u5("span", { + class: "card-title", + style: { margin: 0 }, + children: "Media Cache" + }, undefined, false, undefined, this), + /* @__PURE__ */ u5("button", { + class: "send-btn", + style: { padding: "4px 12px", fontSize: "12px" }, + onClick: load, + children: "Refresh" + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ u5("div", { + class: "log-filter-chips", + style: { marginBottom: "10px" }, + children: [ + { label: "All", value: "" }, + { label: "PDF OCR", value: "pdf_ocr" }, + { label: "PDF Text", value: "pdf_text" }, + { label: "Image", value: "image_desc" } + ].map((f4) => /* @__PURE__ */ u5("button", { + class: `log-filter-chip${filter === f4.value ? " active" : ""}`, + onClick: () => setFilter(f4.value), + children: f4.label + }, f4.value, false, undefined, this)) + }, undefined, false, undefined, this), + loading && !entries ? /* @__PURE__ */ u5("div", { + class: "loading", + style: { padding: "12px" }, + children: "Loading cache..." + }, undefined, false, undefined, this) : !entries || entries.length === 0 ? /* @__PURE__ */ u5("div", { + class: "empty-state", + style: { padding: "24px 0" }, + children: "No cached items." + }, undefined, false, undefined, this) : entries.map((e3) => { + const tc = TYPE_COLORS[e3.type] || TYPE_COLORS.image_desc; + const isExpanded = expanded === e3.hash + ":" + e3.type; + const preview = e3.result.length > 80 ? e3.result.substring(0, 80) + "..." : e3.result; + return /* @__PURE__ */ u5("div", { + style: { + padding: "10px 0", + borderBottom: "1px solid var(--glass-divider)", + cursor: "pointer" + }, + onClick: () => setExpanded(isExpanded ? null : e3.hash + ":" + e3.type), + children: [ + /* @__PURE__ */ u5("div", { + style: { + display: "flex", + alignItems: "center", + gap: "8px" + }, + children: [ + /* @__PURE__ */ u5("span", { + style: { + fontSize: "10px", + fontWeight: 600, + padding: "2px 6px", + borderRadius: "8px", + background: tc.bg, + color: tc.text, + flexShrink: 0 + }, + children: TYPE_LABELS[e3.type] || e3.type + }, undefined, false, undefined, this), + /* @__PURE__ */ u5("span", { + style: { + fontSize: "13px", + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }, + children: preview || "(empty)" + }, undefined, false, undefined, this), + e3.pages ? /* @__PURE__ */ u5("span", { + style: { + fontSize: "11px", + color: "var(--hint)", + flexShrink: 0 + }, + children: [ + e3.pages, + "p" + ] + }, undefined, true, undefined, this) : null + ] + }, undefined, true, undefined, this), + isExpanded && /* @__PURE__ */ u5("div", { + style: { + marginTop: "8px", + fontSize: "12px", + color: "var(--hint)" + }, + children: [ + /* @__PURE__ */ u5("div", { + style: { marginBottom: "4px" }, + children: [ + /* @__PURE__ */ u5("span", { + style: { fontWeight: 600 }, + children: "Hash:" + }, undefined, false, undefined, this), + " ", + /* @__PURE__ */ u5("code", { + style: { fontSize: "11px" }, + children: e3.hash + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + e3.file_path && /* @__PURE__ */ u5("div", { + style: { marginBottom: "4px" }, + children: [ + /* @__PURE__ */ u5("span", { + style: { fontWeight: 600 }, + children: "File:" + }, undefined, false, undefined, this), + " ", + /* @__PURE__ */ u5("code", { + style: { + fontSize: "11px", + wordBreak: "break-all" + }, + children: e3.file_path + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ u5("div", { + style: { marginBottom: "4px" }, + children: [ + /* @__PURE__ */ u5("span", { + style: { fontWeight: 600 }, + children: "Created:" + }, undefined, false, undefined, this), + " ", + formatDate(e3.created_at) + ] + }, undefined, true, undefined, this), + /* @__PURE__ */ u5("div", { + style: { marginBottom: "4px" }, + children: [ + /* @__PURE__ */ u5("span", { + style: { fontWeight: 600 }, + children: "Accessed:" + }, undefined, false, undefined, this), + " ", + formatDate(e3.accessed_at) + ] + }, undefined, true, undefined, this), + e3.result && /* @__PURE__ */ u5("pre", { + style: { + marginTop: "6px", + fontSize: "11px", + maxHeight: "200px", + overflow: "auto", + background: "var(--secondary-bg)", + padding: "8px", + borderRadius: "6px", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + color: "var(--text)" + }, + children: e3.result + }, undefined, false, undefined, this) + ] + }, undefined, true, undefined, this) + ] + }, e3.hash + ":" + e3.type, true, undefined, this); + }) + ] + }, undefined, true, undefined, this); + } + // src/components/tools/tools-tab.tsx function ToolsTab({ active, sse }) { return /* @__PURE__ */ u5(k, { @@ -10510,6 +10733,9 @@ Please report this to https://github.com/markedjs/marked.`, e3) { }, undefined, false, undefined, this), /* @__PURE__ */ u5(ResearchSection, { active + }, undefined, false, undefined, this), + /* @__PURE__ */ u5(CacheSection, { + active }, undefined, false, undefined, this) ] }, undefined, true, undefined, this); diff --git a/pkg/miniapp/types.go b/pkg/miniapp/types.go index c8d9261a4..874a56097 100644 --- a/pkg/miniapp/types.go +++ b/pkg/miniapp/types.go @@ -114,6 +114,17 @@ type SessionGraphEdge struct { ForkTurnID string `json:"fork_turn_id,omitempty"` } +// MediaCacheEntry represents a single media cache entry for the Mini App. +type MediaCacheEntry 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"` +} + // DataProvider is the read-only interface to agent state for the Mini App API. type DataProvider interface { ListSkills() []skills.SkillInfo @@ -125,6 +136,7 @@ type DataProvider interface { GetGitRepoDetail(name string) GitInfo GetContextInfo() ContextInfo GetSystemPrompt() string + ListMediaCache(entryType string) []MediaCacheEntry } // CommandSender injects a command into the message bus on behalf of a user.