Merge pull request #54 from dj-oyu/feature/pdf-ocr
feat: PDF OCR processing with yomitoku CLI
This commit is contained in:
commit
b2fe6d7fab
14 changed files with 1164 additions and 32 deletions
|
|
@ -7,18 +7,23 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/h2non/filetype"
|
"github.com/h2non/filetype"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/mediacache"
|
"github.com/sipeed/picoclaw/pkg/mediacache"
|
||||||
|
|
@ -216,8 +221,8 @@ func (al *AgentLoop) describeImagesInMessages(
|
||||||
if imageCount > 1 {
|
if imageCount > 1 {
|
||||||
label = fmt.Sprintf("Processing %d images...", imageCount)
|
label = fmt.Sprintf("Processing %d images...", imageCount)
|
||||||
}
|
}
|
||||||
stopIndicator := al.processingIndicator(ctx, channel, chatID, label)
|
indicator := al.processingIndicator(ctx, channel, chatID, label)
|
||||||
defer stopIndicator()
|
defer indicator.Stop()
|
||||||
|
|
||||||
for i, m := range result {
|
for i, m := range result {
|
||||||
if len(m.Media) == 0 {
|
if len(m.Media) == 0 {
|
||||||
|
|
@ -257,7 +262,10 @@ func (al *AgentLoop) describeImage(
|
||||||
hash := mediacache.HashData([]byte(dataURL))
|
hash := mediacache.HashData([]byte(dataURL))
|
||||||
if al.mediaCache != nil {
|
if al.mediaCache != nil {
|
||||||
if cached, ok := al.mediaCache.Get(hash, mediacache.TypeImageDesc); ok {
|
if cached, ok := al.mediaCache.Get(hash, mediacache.TypeImageDesc); ok {
|
||||||
logger.DebugCF("agent", "Image description cache hit", map[string]any{"hash": hash})
|
logger.InfoCF("agent", "Image description (cached)", map[string]any{
|
||||||
|
"hash": hash,
|
||||||
|
"description": cached,
|
||||||
|
})
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +320,11 @@ func (al *AgentLoop) describeImage(
|
||||||
|
|
||||||
desc := strings.TrimSpace(resp.Content)
|
desc := strings.TrimSpace(resp.Content)
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Image described", map[string]any{
|
||||||
|
"hash": hash,
|
||||||
|
"description": desc,
|
||||||
|
})
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache
|
||||||
if al.mediaCache != nil {
|
if al.mediaCache != nil {
|
||||||
if cErr := al.mediaCache.Put(hash, mediacache.TypeImageDesc, desc); cErr != nil {
|
if cErr := al.mediaCache.Put(hash, mediacache.TypeImageDesc, desc); cErr != nil {
|
||||||
|
|
@ -326,15 +339,38 @@ func (al *AgentLoop) describeImage(
|
||||||
// a smooth rotating animation when displayed sequentially.
|
// a smooth rotating animation when displayed sequentially.
|
||||||
var brailleSpinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
var brailleSpinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
|
// progressIndicator manages a braille spinner with a dynamically updatable label.
|
||||||
|
// Call UpdateLabel to change the displayed text during processing.
|
||||||
|
type progressIndicator struct {
|
||||||
|
label atomic.Value // string
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLabel changes the label shown alongside the spinner.
|
||||||
|
func (p *progressIndicator) UpdateLabel(label string) {
|
||||||
|
p.label.Store(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the spinner goroutine.
|
||||||
|
func (p *progressIndicator) Stop() {
|
||||||
|
select {
|
||||||
|
case <-p.done:
|
||||||
|
default:
|
||||||
|
close(p.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// processingIndicator publishes draft status messages with a braille spinner
|
// processingIndicator publishes draft status messages with a braille spinner
|
||||||
// animation to indicate active processing. It runs until the returned stop
|
// animation to indicate active processing. It runs until Stop is called.
|
||||||
// function is called. The label describes what is being processed.
|
// Use UpdateLabel to change the displayed text during long operations.
|
||||||
func (al *AgentLoop) processingIndicator(ctx context.Context, channel, chatID, label string) (stop func()) {
|
func (al *AgentLoop) processingIndicator(ctx context.Context, channel, chatID, label string) *progressIndicator {
|
||||||
|
p := &progressIndicator{done: make(chan struct{})}
|
||||||
|
p.label.Store(label)
|
||||||
|
|
||||||
if al.bus == nil || channel == "" || chatID == "" {
|
if al.bus == nil || channel == "" || chatID == "" {
|
||||||
return func() {}
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(150 * time.Millisecond)
|
ticker := time.NewTicker(150 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
@ -342,12 +378,13 @@ func (al *AgentLoop) processingIndicator(ctx context.Context, channel, chatID, l
|
||||||
frame := 0
|
frame := 0
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-p.done:
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
content := fmt.Sprintf("%s %s", brailleSpinnerFrames[frame%len(brailleSpinnerFrames)], label)
|
lbl, _ := p.label.Load().(string)
|
||||||
|
content := fmt.Sprintf("%s %s", brailleSpinnerFrames[frame%len(brailleSpinnerFrames)], lbl)
|
||||||
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
|
|
@ -359,9 +396,7 @@ func (al *AgentLoop) processingIndicator(ctx context.Context, channel, chatID, l
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return func() {
|
return p
|
||||||
close(done)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectImageDescriptions replaces "[image: photo]" tags in content with
|
// injectImageDescriptions replaces "[image: photo]" tags in content with
|
||||||
|
|
@ -379,3 +414,346 @@ func injectImageDescriptions(content string, descriptions []string) string {
|
||||||
}
|
}
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPreviewRunes is the maximum number of runes to store as preview
|
||||||
|
// in the media cache for PDF OCR results.
|
||||||
|
const maxPreviewRunes = 500
|
||||||
|
|
||||||
|
// figureKeywords triggers --figure --figure_letter when found in the message.
|
||||||
|
//
|
||||||
|
//nolint:gosmopolitan // intentional CJK keywords for Japanese users
|
||||||
|
var figureKeywords = []string{
|
||||||
|
"figure", "figures", "with images",
|
||||||
|
"図版", "図付き", "画像付き", "図も",
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantFigures returns true if the message content contains a figure keyword.
|
||||||
|
func wantFigures(content string) bool {
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
for _, kw := range figureKeywords {
|
||||||
|
if strings.Contains(lower, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfHintMessage = "PDF OCR in progress. " +
|
||||||
|
"Tip: include \"figures\" or \"\u56f3\u7248\" in your message to extract images and in-figure text."
|
||||||
|
|
||||||
|
// processPDFsInMessages finds [file:/path.pdf] tags in messages and replaces
|
||||||
|
// them with [document: preview... (full: /path/to.md, N pages)] tags after
|
||||||
|
// running OCR. A braille spinner with page progress is shown during processing.
|
||||||
|
func (al *AgentLoop) processPDFsInMessages(
|
||||||
|
ctx context.Context, messages []providers.Message, ocrCfg *config.OCRConfig,
|
||||||
|
channel, chatID string,
|
||||||
|
) []providers.Message {
|
||||||
|
result := make([]providers.Message, len(messages))
|
||||||
|
copy(result, messages)
|
||||||
|
|
||||||
|
for i, m := range result {
|
||||||
|
if !strings.Contains(m.Content, "[file:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
withFigures := wantFigures(m.Content)
|
||||||
|
result[i].Content = al.replacePDFTags(
|
||||||
|
ctx, m.Content, ocrCfg, channel, chatID, withFigures,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfTagPrefix is the file tag pattern for PDF files injected by resolveMediaRefs.
|
||||||
|
const pdfTagPrefix = "[file:"
|
||||||
|
|
||||||
|
// replacePDFTags finds [file:*.pdf] tags and replaces them with OCR results.
|
||||||
|
func (al *AgentLoop) replacePDFTags(
|
||||||
|
ctx context.Context, content string, ocrCfg *config.OCRConfig,
|
||||||
|
channel, chatID string, withFigures bool,
|
||||||
|
) string {
|
||||||
|
var out strings.Builder
|
||||||
|
rest := content
|
||||||
|
|
||||||
|
for {
|
||||||
|
idx := strings.Index(rest, pdfTagPrefix)
|
||||||
|
if idx < 0 {
|
||||||
|
out.WriteString(rest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endRel := strings.Index(rest[idx:], "]")
|
||||||
|
if endRel < 0 {
|
||||||
|
out.WriteString(rest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := idx + endRel + 1
|
||||||
|
|
||||||
|
tag := rest[idx:end]
|
||||||
|
path := tag[len(pdfTagPrefix) : len(tag)-1]
|
||||||
|
|
||||||
|
out.WriteString(rest[:idx])
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(path), ".pdf") {
|
||||||
|
out.WriteString(al.ocrPDF(ctx, path, ocrCfg, channel, chatID, withFigures))
|
||||||
|
} else {
|
||||||
|
out.WriteString(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = rest[end:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocrPDF runs OCR on a PDF file and returns a document tag with preview.
|
||||||
|
// Uses the media cache to avoid redundant OCR runs.
|
||||||
|
// When withFigures is true, --figure and --figure_letter flags are added.
|
||||||
|
func (al *AgentLoop) ocrPDF(
|
||||||
|
ctx context.Context, pdfPath string, ocrCfg *config.OCRConfig,
|
||||||
|
channel, chatID string, withFigures bool,
|
||||||
|
) string {
|
||||||
|
// Hash the file content for cache lookup.
|
||||||
|
// Include figure mode in the hash so both variants are cached separately.
|
||||||
|
pdfData, err := os.ReadFile(pdfPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to read PDF", map[string]any{"path": pdfPath, "error": err.Error()})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
hashInput := pdfData
|
||||||
|
if withFigures {
|
||||||
|
hashInput = append(hashInput, []byte(":figures")...)
|
||||||
|
}
|
||||||
|
hash := mediacache.HashData(hashInput)
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if al.mediaCache != nil {
|
||||||
|
if entry, ok := al.mediaCache.GetEntry(hash, mediacache.TypePDFOCR); ok {
|
||||||
|
logger.DebugCF("agent", "PDF OCR cache hit", map[string]any{"hash": hash})
|
||||||
|
return formatDocumentTag(entry.Result, entry.FilePath, entry.Pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get page count for progress display
|
||||||
|
totalPages := mediacache.PDFPageCount(pdfPath)
|
||||||
|
totalStr := mediacache.FormatPageCount(totalPages)
|
||||||
|
|
||||||
|
// Send hint message and start progress indicator
|
||||||
|
if al.bus != nil && channel != "" && chatID != "" {
|
||||||
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: pdfHintMessage,
|
||||||
|
SkipPlaceholder: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
modeLabel := "Processing PDF"
|
||||||
|
if withFigures {
|
||||||
|
modeLabel = "Processing PDF (with figures)"
|
||||||
|
}
|
||||||
|
indicator := al.processingIndicator(ctx, channel, chatID,
|
||||||
|
fmt.Sprintf("%s (0/%s)...", modeLabel, totalStr))
|
||||||
|
defer indicator.Stop()
|
||||||
|
|
||||||
|
// Determine output directory for OCR results
|
||||||
|
outputDir := al.ocrOutputDir()
|
||||||
|
os.MkdirAll(outputDir, 0o755)
|
||||||
|
|
||||||
|
// Build command
|
||||||
|
timeout := time.Duration(ocrCfg.GetOCRTimeout()) * time.Second
|
||||||
|
cmdCtx, cmdCancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cmdCancel()
|
||||||
|
|
||||||
|
args := make([]string, 0, len(ocrCfg.Args)+6)
|
||||||
|
args = append(args, ocrCfg.Args...)
|
||||||
|
if withFigures {
|
||||||
|
args = append(args, "--figure", "--figure_letter")
|
||||||
|
}
|
||||||
|
args = append(args, pdfPath, "-o", outputDir)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(cmdCtx, ocrCfg.Command, args...)
|
||||||
|
|
||||||
|
// Set environment
|
||||||
|
if len(ocrCfg.Env) > 0 {
|
||||||
|
cmd.Env = append(os.Environ(), ocrEnvSlice(ocrCfg.Env)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipe stderr for progress tracking
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to create stderr pipe", map[string]any{"error": err.Error()})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Starting PDF OCR", map[string]any{
|
||||||
|
"path": pdfPath,
|
||||||
|
"pages": totalStr,
|
||||||
|
"cmd": ocrCfg.Command,
|
||||||
|
})
|
||||||
|
|
||||||
|
if startErr := cmd.Start(); startErr != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to start OCR command", map[string]any{"error": startErr.Error()})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress via stderr
|
||||||
|
page := 0
|
||||||
|
scanner := bufio.NewScanner(stderrPipe)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.Contains(line, "TextDetector __call__") {
|
||||||
|
page++
|
||||||
|
indicator.UpdateLabel(fmt.Sprintf("%s (%d/%s)...", modeLabel, page, totalStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if waitErr := cmd.Wait(); waitErr != nil {
|
||||||
|
logger.WarnCF("agent", "OCR command failed", map[string]any{
|
||||||
|
"path": pdfPath,
|
||||||
|
"error": waitErr.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up page images (_pN.jpg) generated by yomitoku.
|
||||||
|
// These are always created and cannot be suppressed via CLI options.
|
||||||
|
// Keep: .md files, figures/ directory (referenced by markdown output).
|
||||||
|
cleanupOCRPageImages(outputDir, pdfPath)
|
||||||
|
|
||||||
|
// Find the output markdown file
|
||||||
|
mdPath := findOCROutput(outputDir, pdfPath)
|
||||||
|
if mdPath == "" {
|
||||||
|
logger.WarnCF("agent", "OCR output not found", map[string]any{"output_dir": outputDir})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read preview from first part of the markdown
|
||||||
|
mdData, err := os.ReadFile(mdPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to read OCR output", map[string]any{"path": mdPath, "error": err.Error()})
|
||||||
|
return fmt.Sprintf("[file:%s]", pdfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := extractPreview(string(mdData), maxPreviewRunes)
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = page // use detected page count as fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
if al.mediaCache != nil {
|
||||||
|
_ = al.mediaCache.PutEntry(hash, mediacache.TypePDFOCR, mediacache.Entry{
|
||||||
|
Result: preview,
|
||||||
|
FilePath: mdPath,
|
||||||
|
Pages: totalPages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "PDF OCR completed", map[string]any{
|
||||||
|
"path": pdfPath,
|
||||||
|
"pages": totalPages,
|
||||||
|
"md_path": mdPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatDocumentTag(preview, mdPath, totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDocumentTag creates the tag injected into message content.
|
||||||
|
func formatDocumentTag(preview, mdPath string, pages int) string {
|
||||||
|
pagesStr := mediacache.FormatPageCount(pages)
|
||||||
|
return fmt.Sprintf("[document: %s\n full: %s (%s pages)\n Use read_file to see the complete document.]",
|
||||||
|
preview, mdPath, pagesStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPreview returns the first maxRunes runes of text, appending "..." if truncated.
|
||||||
|
func extractPreview(text string, maxRunes int) string {
|
||||||
|
runes := []rune(strings.TrimSpace(text))
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocrOutputDir returns the directory for storing OCR markdown output files.
|
||||||
|
func (al *AgentLoop) ocrOutputDir() string {
|
||||||
|
registry := al.GetRegistry()
|
||||||
|
if agent := registry.GetDefaultAgent(); agent != nil {
|
||||||
|
return filepath.Join(agent.Workspace, ".ocr_cache")
|
||||||
|
}
|
||||||
|
return filepath.Join(os.TempDir(), "picoclaw-ocr")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOCROutput locates the markdown file generated by yomitoku.
|
||||||
|
// yomitoku names output as <basename>.md or <basename>_combined.md in the output dir.
|
||||||
|
func findOCROutput(outputDir, pdfPath string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(pdfPath), filepath.Ext(pdfPath))
|
||||||
|
|
||||||
|
// Try common yomitoku output patterns
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(outputDir, base+".md"),
|
||||||
|
filepath.Join(outputDir, base+"_combined.md"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: find any .md file in the output directory
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
|
||||||
|
return filepath.Join(outputDir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocrEnvSlice converts a map to "KEY=VALUE" slice for exec.Cmd.Env.
|
||||||
|
func ocrEnvSlice(env map[string]string) []string {
|
||||||
|
result := make([]string, 0, len(env))
|
||||||
|
for k, v := range env {
|
||||||
|
result = append(result, k+"="+v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOCRPageImages removes _pN.jpg files generated by yomitoku.
|
||||||
|
// These per-page images are always created by the CLI and cannot be suppressed.
|
||||||
|
// Only top-level _pN.jpg files matching the PDF basename are removed;
|
||||||
|
// the figures/ subdirectory and .md files are preserved.
|
||||||
|
func cleanupOCRPageImages(outputDir, pdfPath string) {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(pdfPath), filepath.Ext(pdfPath))
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
// Match pattern: <dirname>_<basename>_pN.jpg
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".jpg") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(name, base+"_p") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rmErr := os.Remove(filepath.Join(outputDir, name)); rmErr == nil {
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
logger.DebugCF("agent", "Cleaned up OCR page images", map[string]any{
|
||||||
|
"dir": outputDir,
|
||||||
|
"removed": removed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -104,3 +105,94 @@ func TestResolveImageModel_FallsToPlanModel(t *testing.T) {
|
||||||
t.Errorf("got %q, want %q", model, "openai/gpt-5.4-nano")
|
t.Errorf("got %q, want %q", model, "openai/gpt-5.4-nano")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractPreview_Short(t *testing.T) {
|
||||||
|
text := "Hello world"
|
||||||
|
result := extractPreview(text, 500)
|
||||||
|
if result != "Hello world" {
|
||||||
|
t.Errorf("got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPreview_Truncated(t *testing.T) {
|
||||||
|
text := strings.Repeat("a", 600)
|
||||||
|
result := extractPreview(text, 500)
|
||||||
|
if len([]rune(result)) != 503 { // 500 + "..."
|
||||||
|
t.Errorf("len = %d, want 503", len([]rune(result)))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(result, "...") {
|
||||||
|
t.Error("should end with ...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDocumentTag(t *testing.T) {
|
||||||
|
tag := formatDocumentTag("preview text", "/path/to/doc.md", 18)
|
||||||
|
if !strings.Contains(tag, "preview text") {
|
||||||
|
t.Error("should contain preview")
|
||||||
|
}
|
||||||
|
if !strings.Contains(tag, "/path/to/doc.md") {
|
||||||
|
t.Error("should contain file path")
|
||||||
|
}
|
||||||
|
if !strings.Contains(tag, "18 pages") {
|
||||||
|
t.Error("should contain page count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(tag, "read_file") {
|
||||||
|
t.Error("should contain read_file hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDocumentTag_UnknownPages(t *testing.T) {
|
||||||
|
tag := formatDocumentTag("preview", "/path.md", 0)
|
||||||
|
if !strings.Contains(tag, "? pages") {
|
||||||
|
t.Error("should show ? for unknown page count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacePDFTags_NoPDF(t *testing.T) {
|
||||||
|
al := &AgentLoop{}
|
||||||
|
content := "Check this out [file:/path/to/audio.mp3]"
|
||||||
|
result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false)
|
||||||
|
if result != content {
|
||||||
|
t.Errorf("non-PDF should be unchanged, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacePDFTags_NoTags(t *testing.T) {
|
||||||
|
al := &AgentLoop{}
|
||||||
|
content := "Hello world"
|
||||||
|
result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false)
|
||||||
|
if result != content {
|
||||||
|
t.Errorf("no tags should be unchanged, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessPDFs_NilOCR(t *testing.T) {
|
||||||
|
// When OCR is not configured, messages should pass through unchanged
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "Check [file:/tmp/doc.pdf]"},
|
||||||
|
}
|
||||||
|
al := &AgentLoop{}
|
||||||
|
result := al.processPDFsInMessages(t.Context(), messages, nil, "", "")
|
||||||
|
if result[0].Content != messages[0].Content {
|
||||||
|
t.Errorf("content should be unchanged when OCR config is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWantFigures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
content string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"check this pdf", false},
|
||||||
|
{"extract with figures please", true},
|
||||||
|
{"Figures included", true},
|
||||||
|
{"figure mode", true},
|
||||||
|
{"with images", true},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := wantFigures(tt.content); got != tt.want {
|
||||||
|
t.Errorf("wantFigures(%q) = %v, want %v", tt.content, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,11 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
messages = al.describeImagesInMessages(ctx, messages, agent, opts.Channel, opts.ChatID)
|
messages = al.describeImagesInMessages(ctx, messages, agent, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process PDFs with OCR when configured
|
||||||
|
if ocrCfg := cfg.Agents.Defaults.OCR; ocrCfg != nil && ocrCfg.Command != "" {
|
||||||
|
messages = al.processPDFsInMessages(ctx, messages, ocrCfg, opts.Channel, opts.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
// 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for
|
// 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for
|
||||||
|
|
||||||
// several consecutive turns, inject a reminder so the AI writes its findings.
|
// several consecutive turns, inject a reminder so the AI writes its findings.
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,23 @@ type AgentDefaults struct {
|
||||||
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
|
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
|
||||||
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
|
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
|
||||||
Routing *RoutingConfig `json:"routing,omitempty"`
|
Routing *RoutingConfig `json:"routing,omitempty"`
|
||||||
|
OCR *OCRConfig `json:"ocr,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCRConfig configures the external OCR command for PDF text extraction.
|
||||||
|
type OCRConfig struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOCRTimeout returns the configured timeout or default (600s = 10min).
|
||||||
|
func (c *OCRConfig) GetOCRTimeout() int {
|
||||||
|
if c != nil && c.Timeout > 0 {
|
||||||
|
return c.Timeout
|
||||||
|
}
|
||||||
|
return 600
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,50 @@ 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) {
|
||||||
|
|
|
||||||
52
pkg/mediacache/pdf.go
Normal file
52
pkg/mediacache/pdf.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package mediacache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pdfPageCountRe matches /Type /Pages ... /Count N in PDF cross-reference.
|
||||||
|
// This covers the vast majority of well-formed PDFs.
|
||||||
|
var pdfPageCountRe = regexp.MustCompile(`/Type\s*/Pages\b[^>]*/Count\s+(\d+)`)
|
||||||
|
|
||||||
|
// PDFPageCount extracts the total page count from a PDF file by parsing
|
||||||
|
// the /Type /Pages dictionary. Returns 0 if the count cannot be determined
|
||||||
|
// (encrypted, malformed, or unusual structure). This is a best-effort
|
||||||
|
// extraction that avoids heavy PDF library dependencies.
|
||||||
|
func PDFPageCount(path string) int {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Search from the end of the file where the root Pages dict typically lives.
|
||||||
|
// Limit search to last 64KB for performance on large files.
|
||||||
|
searchStart := 0
|
||||||
|
if len(data) > 64*1024 {
|
||||||
|
searchStart = len(data) - 64*1024
|
||||||
|
}
|
||||||
|
matches := pdfPageCountRe.FindAllSubmatch(data[searchStart:], -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
// Fallback: search entire file
|
||||||
|
matches = pdfPageCountRe.FindAllSubmatch(data, -1)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Use the largest /Count found (root Pages object has the total).
|
||||||
|
var maxCount int
|
||||||
|
for _, m := range matches {
|
||||||
|
if n, err := strconv.Atoi(string(m[1])); err == nil && n > maxCount {
|
||||||
|
maxCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPageCount returns pages as a string, or "?" if unknown.
|
||||||
|
func FormatPageCount(pages int) string {
|
||||||
|
if pages > 0 {
|
||||||
|
return strconv.Itoa(pages)
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
81
pkg/mediacache/pdf_test.go
Normal file
81
pkg/mediacache/pdf_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package mediacache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minimalPDF is a valid PDF with 3 pages.
|
||||||
|
// This is the smallest possible multi-page PDF structure.
|
||||||
|
const minimalPDF = `%PDF-1.4
|
||||||
|
1 0 obj <</Type /Catalog /Pages 2 0 R>> endobj
|
||||||
|
2 0 obj <</Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3>> endobj
|
||||||
|
3 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>> endobj
|
||||||
|
4 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>> endobj
|
||||||
|
5 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>> endobj
|
||||||
|
xref
|
||||||
|
0 6
|
||||||
|
trailer <</Size 6 /Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
0
|
||||||
|
%%EOF`
|
||||||
|
|
||||||
|
func TestPDFPageCount_ValidPDF(t *testing.T) {
|
||||||
|
path := writeTempFile(t, "test.pdf", minimalPDF)
|
||||||
|
count := PDFPageCount(path)
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("PDFPageCount = %d, want 3", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFPageCount_NonExistent(t *testing.T) {
|
||||||
|
count := PDFPageCount("/nonexistent/file.pdf")
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("PDFPageCount = %d, want 0 for missing file", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFPageCount_NotPDF(t *testing.T) {
|
||||||
|
path := writeTempFile(t, "test.txt", "hello world")
|
||||||
|
count := PDFPageCount(path)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("PDFPageCount = %d, want 0 for non-PDF", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPDFPageCount_SinglePage(t *testing.T) {
|
||||||
|
pdf := `%PDF-1.4
|
||||||
|
1 0 obj <</Type /Catalog /Pages 2 0 R>> endobj
|
||||||
|
2 0 obj <</Type /Pages /Kids [3 0 R] /Count 1>> endobj
|
||||||
|
3 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>> endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
trailer <</Size 4 /Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
0
|
||||||
|
%%EOF`
|
||||||
|
path := writeTempFile(t, "single.pdf", pdf)
|
||||||
|
count := PDFPageCount(path)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("PDFPageCount = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPageCount(t *testing.T) {
|
||||||
|
if s := FormatPageCount(0); s != "?" {
|
||||||
|
t.Errorf("FormatPageCount(0) = %q, want %q", s, "?")
|
||||||
|
}
|
||||||
|
if s := FormatPageCount(18); s != "18" {
|
||||||
|
t.Errorf("FormatPageCount(18) = %q, want %q", s, "18")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTempFile(t *testing.T, name, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
128
web/backend/api/media_cache.go
Normal file
128
web/backend/api/media_cache.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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=="],
|
||||||
|
|
|
||||||
40
web/frontend/src/api/media-cache.ts
Normal file
40
web/frontend/src/api/media-cache.ts
Normal 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)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
227
web/frontend/src/components/research/media-cache-page.tsx
Normal file
227
web/frontend/src/components/research/media-cache-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue