mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* stage 12: PDF ingestion Add PDF ingestion to internal/imageinput so specs, design docs, and tickets that arrive as PDF can be attached the same way images are. - internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes (%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a 256 KiB extracted-text cap (truncated with a marker, not rejected), and extracts the text layer in pure Go via github.com/ledongthuc/pdf. The parser can panic on malformed structures, so extraction is wrapped in a recover that turns a bad PDF into a clean error. A textless PDF with no rasterizer returns an explicit "no extractable text; OCR not available" error rather than silent empty success. - Dependency posture: default build is pure-Go text extraction (no CGO, no transitive deps) so the static binary still cross-compiles. The optional poppler tools (pdftotext / pdftoppm) are used for richer extraction and page rasterization ONLY when present on PATH (same posture as the LSP language servers); when absent, extraction degrades to the pure-Go text path and never errors. - Vision path: with a vision model and pdftoppm available, the first N pages (default 10) render to PNG and flow through the existing image pipeline (NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer it degrades to the text layer. - TUI integration: /image <file.pdf> routes to the document path. The text layer attaches for any model and is prepended to the next prompt as a labeled preamble; rendered pages attach as images for vision models. The chip row and /image clear cover documents too. Tests cover magic-byte detection, text extraction on a generated fixture, both size caps, the no-text/no-raster message, malformed-input safety, the pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test, and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64, windows/arm64, and darwin/arm64. * stage 12: address review (pdf page count, text cap, content-based routing)
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/imageinput"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
)
|
|
|
|
// modelSupportsVisionTUI reports whether the active model can accept image input.
|
|
// An unknown / custom id (not in the catalog) returns false: we cannot confirm
|
|
// vision support, so the TUI refuses to attach rather than silently sending
|
|
// images a model may reject. Mirrors the CLI/headless vision gate (component E).
|
|
func modelSupportsVisionTUI(modelName string) bool {
|
|
trimmed := strings.TrimSpace(modelName)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return modelregistry.SupportsVision(registry, trimmed)
|
|
}
|
|
|
|
// handleImageCommand processes "/image <path>" and "/image clear". A bare
|
|
// "/image" prints usage. PDFs are routed to the document path (text layer always
|
|
// attaches; pages rasterize to images only for vision models with a rasterizer).
|
|
// Image files attach only to vision models. Attachment failures (missing file,
|
|
// unsupported type, oversize) surface as an inline notice and attach nothing.
|
|
func (m model) handleImageCommand(arg string) model {
|
|
trimmed := strings.TrimSpace(arg)
|
|
switch {
|
|
case trimmed == "":
|
|
return m.appendImageNotice("Usage: /image <path> (image or PDF; or /image clear)")
|
|
case strings.EqualFold(trimmed, "clear"):
|
|
m.pendingImages = nil
|
|
m.pendingImageLabels = nil
|
|
m.pendingDocuments = nil
|
|
return m.appendImageNotice("Cleared pending attachments.")
|
|
}
|
|
|
|
// A PDF carries a text layer every model can read, so it is not gated on
|
|
// vision the way a raw image is; the optional rasterized pages are. Route by
|
|
// the ".pdf" hint OR a content sniff so a real PDF whose name lacks the
|
|
// extension still reaches the document path rather than the vision-only image
|
|
// path. The cheap header sniff runs before the vision gate.
|
|
if imageinput.IsProbablyDocumentPath(trimmed) || imageinput.LooksLikeDocumentFile(trimmed, m.cwd) {
|
|
return m.handleDocumentAttach(trimmed)
|
|
}
|
|
|
|
if !modelSupportsVisionTUI(m.modelName) {
|
|
name := m.modelName
|
|
if name == "" {
|
|
name = "the active model"
|
|
}
|
|
return m.appendImageNotice("Model " + name + " does not support image input; attachment refused.")
|
|
}
|
|
|
|
block, err := imageinput.LoadFile(trimmed, m.cwd)
|
|
if err != nil {
|
|
return m.appendImageNotice(err.Error())
|
|
}
|
|
|
|
m.pendingImages = append(m.pendingImages, block)
|
|
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
|
|
return m.appendImageNotice("Attached " + filepath.Base(trimmed) + " (" + block.MediaType + ").")
|
|
}
|
|
|
|
// pendingDocument is a PDF staged by /image for the next user turn: its extracted
|
|
// text layer (prepended to the prompt at submit time) and a display label.
|
|
type pendingDocument struct {
|
|
label string
|
|
text string
|
|
}
|
|
|
|
// handleDocumentAttach loads a PDF through imageinput.LoadDocument. The text
|
|
// layer is staged for every model; when the active model supports vision and a
|
|
// rasterizer is available, the rendered pages are staged through the existing
|
|
// pending-image pipeline too. A scanned PDF with no text (and no rasterizer)
|
|
// surfaces LoadDocument's explicit "no extractable text" notice and attaches
|
|
// nothing.
|
|
func (m model) handleDocumentAttach(path string) model {
|
|
doc, err := imageinput.LoadDocument(path, m.cwd, imageinput.DocumentOptions{
|
|
Vision: modelSupportsVisionTUI(m.modelName),
|
|
})
|
|
if err != nil {
|
|
return m.appendImageNotice(err.Error())
|
|
}
|
|
|
|
label := filepath.Base(path)
|
|
parts := make([]string, 0, 2)
|
|
if strings.TrimSpace(doc.Text) != "" {
|
|
m.pendingDocuments = append(m.pendingDocuments, pendingDocument{label: label, text: doc.Text})
|
|
summary := "text"
|
|
if doc.Truncated {
|
|
summary = "text, truncated at the size limit"
|
|
}
|
|
parts = append(parts, summary)
|
|
}
|
|
for _, block := range doc.Images {
|
|
m.pendingImages = append(m.pendingImages, block)
|
|
m.pendingImageLabels = append(m.pendingImageLabels, label)
|
|
}
|
|
if len(doc.Images) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d page image(s)", len(doc.Images)))
|
|
}
|
|
|
|
return m.appendImageNotice("Attached " + label + " (" + strings.Join(parts, ", ") + ").")
|
|
}
|
|
|
|
// consumePendingDocuments returns the staged document text formatted as a prompt
|
|
// preamble and clears the pending documents. The preamble names each document so
|
|
// the model can attribute the text; an empty result means nothing was staged.
|
|
func (m *model) consumePendingDocuments() string {
|
|
if len(m.pendingDocuments) == 0 {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for _, doc := range m.pendingDocuments {
|
|
b.WriteString("Attached document: ")
|
|
b.WriteString(doc.label)
|
|
b.WriteString("\n")
|
|
b.WriteString(doc.text)
|
|
b.WriteString("\n\n")
|
|
}
|
|
m.pendingDocuments = nil
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) appendImageNotice(text string) model {
|
|
return m.appendSystemNotice(text)
|
|
}
|
|
|
|
// renderImageChips builds a one-line "[img: a.png] [img: b.png]" row for the
|
|
// pending attachments, or "" when there are none. Kept plain so the renderer
|
|
// can wrap/style it consistently.
|
|
func renderImageChips(labels []string) string {
|
|
if len(labels) == 0 {
|
|
return ""
|
|
}
|
|
chips := make([]string, 0, len(labels))
|
|
for _, label := range labels {
|
|
chips = append(chips, "[img: "+label+"]")
|
|
}
|
|
return strings.Join(chips, " ")
|
|
}
|
|
|
|
// renderAttachmentChips builds the pending-attachment row from both staged
|
|
// images and staged documents, e.g. "[img: a.png] [doc: spec.pdf]". Returns ""
|
|
// when nothing is staged. Document chips are de-duplicated by label so a PDF
|
|
// that produced both a text block and several page images shows one "[doc: …]"
|
|
// rather than one per page (its page images already show as "[img: …]").
|
|
func renderAttachmentChips(imageLabels []string, docs []pendingDocument) string {
|
|
chips := make([]string, 0, len(imageLabels)+len(docs))
|
|
for _, label := range imageLabels {
|
|
chips = append(chips, "[img: "+label+"]")
|
|
}
|
|
for _, doc := range docs {
|
|
chips = append(chips, "[doc: "+doc.label+"]")
|
|
}
|
|
return strings.Join(chips, " ")
|
|
}
|