fix: Ollama timeout + image compression + selective sudo in install.sh
This commit is contained in:
parent
357483cfd2
commit
b6c2c3f99d
2 changed files with 102 additions and 32 deletions
64
install.sh
64
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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue