diff --git a/install.sh b/install.sh index a09b30e..f0a0035 100755 --- a/install.sh +++ b/install.sh @@ -4,10 +4,12 @@ # ===================== # # Usage: -# sudo ./install.sh — Full install (clone, build, configure, service) -# sudo ./install.sh -update — Pull latest, rebuild, restart +# curl -fsSL https://git.lohmar.co.uk/cclohmar/ReceiptNext/raw/branch/main/install.sh | bash +# bash install.sh — Full install +# bash install.sh -update — Pull latest, rebuild, restart # # Installs to /opt/receiptnext/ and sets up a systemd service. +# Uses sudo internally only for operations that require it. # set -euo pipefail @@ -27,10 +29,14 @@ warn() { echo -e "${YELLOW}[!]${NC} $1"; } err() { echo -e "${RED}[✗]${NC} $1"; } ask() { echo -en "${CYAN}→${NC} $1"; } -if [ "$EUID" -ne 0 ]; then - err "Please run as root: sudo ./install.sh" - exit 1 -fi +# Helper: run a command with sudo if not already root +sudo_if() { + if [ "$EUID" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} # ────────────────────────────────────────────────────────────────── # UPDATE MODE @@ -51,13 +57,13 @@ if [ "${1:-}" = "-update" ]; then if [ -d .git ]; then info "Pulling latest code..." - git pull + sudo_if git pull else warn "Not a git repository — re-cloning..." cd /tmp rm -rf receiptnext-update - git clone "$REPO_URL" receiptnext-update - rsync -a --delete receiptnext-update/ "$INSTALL_DIR/" + sudo_if git clone "$REPO_URL" receiptnext-update + sudo_if rsync -a --delete receiptnext-update/ "$INSTALL_DIR/" rm -rf receiptnext-update cd "$INSTALL_DIR" fi @@ -90,10 +96,10 @@ echo "" if [ -d "$INSTALL_DIR" ]; then warn "$INSTALL_DIR already exists — pulling latest..." cd "$INSTALL_DIR" - git pull + sudo_if git pull else info "Cloning repository to $INSTALL_DIR..." - git clone "$REPO_URL" "$INSTALL_DIR" + sudo_if git clone "$REPO_URL" "$INSTALL_DIR" cd "$INSTALL_DIR" fi @@ -103,8 +109,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o receiptnext . info "Binary built: $INSTALL_DIR/receiptnext" # ── 3. Create runtime directories ──────────────────────────────── -mkdir -p storage -chmod 755 templates static storage +sudo_if mkdir -p storage +sudo_if chmod 755 templates static storage # ── 4. AI Provider ─────────────────────────────────────────────── echo "" @@ -137,20 +143,20 @@ case "$AI_CHOICE" in if ! command -v zstd &>/dev/null; then info "Installing zstd (required by Ollama)..." if command -v apt-get &>/dev/null; then - apt-get update -qq && apt-get install -y -qq zstd 2>&1 | tail -1 + sudo_if apt-get update -qq && apt-get install -y -qq zstd 2>&1 | tail -1 elif command -v dnf &>/dev/null; then - dnf install -y zstd 2>&1 | tail -1 + sudo_if dnf install -y zstd 2>&1 | tail -1 elif command -v yum &>/dev/null; then - yum install -y zstd 2>&1 | tail -1 + sudo_if yum install -y zstd 2>&1 | tail -1 elif command -v apk &>/dev/null; then - apk add zstd 2>&1 | tail -1 + sudo_if apk add zstd 2>&1 | tail -1 else warn "Please install zstd manually, then re-run install.sh" fi fi info "Installing Ollama..." if ! command -v ollama &>/dev/null; then - curl -fsSL https://ollama.com/install.sh | sh 2>&1 | tail -5 + sudo_if sh -c "$(curl -fsSL https://ollama.com/install.sh)" 2>&1 | tail -5 else info "Ollama already installed" fi @@ -160,7 +166,7 @@ case "$AI_CHOICE" in sleep 2 done info "Pulling model $AI_MODEL (this may take a while)..." - ollama pull "$AI_MODEL" 2>&1 | tail -3 + sudo_if ollama pull "$AI_MODEL" 2>&1 | tail -3 AI_BASE_URL="http://localhost:11434" ;; *) @@ -213,7 +219,7 @@ fi # ── 7. Write .env ──────────────────────────────────────────────── info "Creating .env..." -cat > "$INSTALL_DIR/.env" << ENVEOF +sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF # ReceiptNext Configuration # Generated by install.sh on $(date) @@ -224,27 +230,27 @@ ENVEOF case "$AI_PROVIDER" in openai) - cat >> "$INSTALL_DIR/.env" << ENVEOF + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF OPENAI_API_KEY=${OPENAI_API_KEY} AI_MODEL=${AI_MODEL} AI_BASE_URL=${AI_BASE_URL} ENVEOF ;; ollama) - cat >> "$INSTALL_DIR/.env" << ENVEOF + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF AI_MODEL=${AI_MODEL} AI_BASE_URL=${AI_BASE_URL} ENVEOF ;; gemini) - cat >> "$INSTALL_DIR/.env" << ENVEOF + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF GEMINI_API_KEY=${GEMINI_API_KEY} ENVEOF ;; esac if [ -n "$SMTP_HOST" ]; then - cat >> "$INSTALL_DIR/.env" << ENVEOF + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF SMTP_HOST=${SMTP_HOST} SMTP_PORT=${SMTP_PORT} SMTP_USER=${SMTP_USER} @@ -252,12 +258,12 @@ SMTP_PASS=${SMTP_PASS} ENVEOF fi -chmod 600 "$INSTALL_DIR/.env" +sudo_if chmod 600 "$INSTALL_DIR/.env" info "Configuration saved to $INSTALL_DIR/.env" # ── 8. Systemd service ─────────────────────────────────────────── info "Creating systemd service..." -cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SERVEOF +sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF [Unit] Description=ReceiptNext — AI-Powered Expense Tracker Documentation=https://git.lohmar.co.uk/cclohmar/ReceiptNext @@ -279,13 +285,13 @@ StandardError=append:/var/log/receiptnext.log WantedBy=multi-user.target SERVEOF -systemctl daemon-reload -systemctl enable "${SERVICE_NAME}" +sudo_if systemctl daemon-reload +sudo_if systemctl enable "${SERVICE_NAME}" info "Service created: /etc/systemd/system/${SERVICE_NAME}.service" # ── 9. Start ───────────────────────────────────────────────────── info "Starting ReceiptNext..." -systemctl restart "${SERVICE_NAME}" 2>/dev/null || true +sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true sleep 2 echo "" diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go index 9fcd765..c69d73a 100644 --- a/internal/ai/ollama.go +++ b/internal/ai/ollama.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "image" + "image/jpeg" "io" "log" "net/http" @@ -13,7 +15,11 @@ import ( "time" ) -const ollamaTimeout = 60 * time.Second +const ( + ollamaTimeout = 300 * time.Second + ollamaMaxSize = 800 + ollamaQuality = 60 +) type ollamaRequest struct { Model string `json:"model"` @@ -43,6 +49,15 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) return &ReceiptData{}, fmt.Errorf("read file: %w", err) } + // Compress the image before base64 encoding: resize to max 800px + // and JPEG-compress at quality 60 to keep the payload manageable + // for a local 2.3B model. + compressed, err := compressImageForOllama(imageData) + if err == nil && len(compressed) < len(imageData) { + imageData = compressed + log.Printf("ExtractReceipt [ollama]: compressed %d -> %d bytes", len(imageData), len(compressed)) + } + baseURL := os.Getenv("AI_BASE_URL") if baseURL == "" { baseURL = "http://localhost:11434" @@ -51,7 +66,7 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) model := os.Getenv("AI_MODEL") if model == "" { - model = "llava" + model = "qwen3.5:2b" } b64Data := base64.StdEncoding.EncodeToString(imageData) @@ -61,9 +76,10 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) Stream: false, Messages: []ollamaMessage{{ Role: "user", - Content: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string), \"amount\" (number), \"currency\" (string, 3-letter code), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD). Return ONLY valid JSON. No markdown.", + Content: "Analyze this receipt image. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (Food/Travel/Lodging/Software/Other), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown.", Images: []string{b64Data}, }}, + Options: map[string]any{"num_predict": 256}, } body, _ := json.Marshal(payload) @@ -100,3 +116,51 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) model, receipt.Merchant, receipt.Amount, receipt.Currency) return &receipt, nil } + +// compressImageForOllama resizes the image to fit within maxSize and +// re-encodes as JPEG at the given quality to reduce the base64 payload. +func compressImageForOllama(data []byte) ([]byte, error) { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + // If already small enough, re-encode at lower quality + if w <= ollamaMaxSize && h <= ollamaMaxSize { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: ollamaQuality}); err != nil { + return nil, err + } + return buf.Bytes(), nil + } + + // Scale down + ratio := float64(ollamaMaxSize) / float64(max(w, h)) + nw := int(float64(w) * ratio) + nh := int(float64(h) * ratio) + if nw < 1 { + nw = 1 + } + if nh < 1 { + nh = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + for y := 0; y < nh; y++ { + for x := 0; x < nw; x++ { + sx := x * w / nw + sy := y * h / nh + dst.Set(x, y, img.At(sx, sy)) + } + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: ollamaQuality}); err != nil { + return nil, err + } + return buf.Bytes(), nil +}