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
62
install.sh
62
install.sh
|
|
@ -4,10 +4,12 @@
|
||||||
# =====================
|
# =====================
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# sudo ./install.sh — Full install (clone, build, configure, service)
|
# curl -fsSL https://git.lohmar.co.uk/cclohmar/ReceiptNext/raw/branch/main/install.sh | bash
|
||||||
# sudo ./install.sh -update — Pull latest, rebuild, restart
|
# bash install.sh — Full install
|
||||||
|
# bash install.sh -update — Pull latest, rebuild, restart
|
||||||
#
|
#
|
||||||
# Installs to /opt/receiptnext/ and sets up a systemd service.
|
# Installs to /opt/receiptnext/ and sets up a systemd service.
|
||||||
|
# Uses sudo internally only for operations that require it.
|
||||||
#
|
#
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -27,10 +29,14 @@ warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||||
err() { echo -e "${RED}[✗]${NC} $1"; }
|
err() { echo -e "${RED}[✗]${NC} $1"; }
|
||||||
ask() { echo -en "${CYAN}→${NC} $1"; }
|
ask() { echo -en "${CYAN}→${NC} $1"; }
|
||||||
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
# Helper: run a command with sudo if not already root
|
||||||
err "Please run as root: sudo ./install.sh"
|
sudo_if() {
|
||||||
exit 1
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
else
|
||||||
|
sudo "$@"
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# UPDATE MODE
|
# UPDATE MODE
|
||||||
|
|
@ -51,13 +57,13 @@ if [ "${1:-}" = "-update" ]; then
|
||||||
|
|
||||||
if [ -d .git ]; then
|
if [ -d .git ]; then
|
||||||
info "Pulling latest code..."
|
info "Pulling latest code..."
|
||||||
git pull
|
sudo_if git pull
|
||||||
else
|
else
|
||||||
warn "Not a git repository — re-cloning..."
|
warn "Not a git repository — re-cloning..."
|
||||||
cd /tmp
|
cd /tmp
|
||||||
rm -rf receiptnext-update
|
rm -rf receiptnext-update
|
||||||
git clone "$REPO_URL" receiptnext-update
|
sudo_if git clone "$REPO_URL" receiptnext-update
|
||||||
rsync -a --delete receiptnext-update/ "$INSTALL_DIR/"
|
sudo_if rsync -a --delete receiptnext-update/ "$INSTALL_DIR/"
|
||||||
rm -rf receiptnext-update
|
rm -rf receiptnext-update
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
@ -90,10 +96,10 @@ echo ""
|
||||||
if [ -d "$INSTALL_DIR" ]; then
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
warn "$INSTALL_DIR already exists — pulling latest..."
|
warn "$INSTALL_DIR already exists — pulling latest..."
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
git pull
|
sudo_if git pull
|
||||||
else
|
else
|
||||||
info "Cloning repository to $INSTALL_DIR..."
|
info "Cloning repository to $INSTALL_DIR..."
|
||||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
sudo_if git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -103,8 +109,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o receiptnext .
|
||||||
info "Binary built: $INSTALL_DIR/receiptnext"
|
info "Binary built: $INSTALL_DIR/receiptnext"
|
||||||
|
|
||||||
# ── 3. Create runtime directories ────────────────────────────────
|
# ── 3. Create runtime directories ────────────────────────────────
|
||||||
mkdir -p storage
|
sudo_if mkdir -p storage
|
||||||
chmod 755 templates static storage
|
sudo_if chmod 755 templates static storage
|
||||||
|
|
||||||
# ── 4. AI Provider ───────────────────────────────────────────────
|
# ── 4. AI Provider ───────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -137,20 +143,20 @@ case "$AI_CHOICE" in
|
||||||
if ! command -v zstd &>/dev/null; then
|
if ! command -v zstd &>/dev/null; then
|
||||||
info "Installing zstd (required by Ollama)..."
|
info "Installing zstd (required by Ollama)..."
|
||||||
if command -v apt-get &>/dev/null; then
|
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
|
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
|
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
|
elif command -v apk &>/dev/null; then
|
||||||
apk add zstd 2>&1 | tail -1
|
sudo_if apk add zstd 2>&1 | tail -1
|
||||||
else
|
else
|
||||||
warn "Please install zstd manually, then re-run install.sh"
|
warn "Please install zstd manually, then re-run install.sh"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
info "Installing Ollama..."
|
info "Installing Ollama..."
|
||||||
if ! command -v ollama &>/dev/null; then
|
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
|
else
|
||||||
info "Ollama already installed"
|
info "Ollama already installed"
|
||||||
fi
|
fi
|
||||||
|
|
@ -160,7 +166,7 @@ case "$AI_CHOICE" in
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
info "Pulling model $AI_MODEL (this may take a while)..."
|
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"
|
AI_BASE_URL="http://localhost:11434"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
@ -213,7 +219,7 @@ fi
|
||||||
|
|
||||||
# ── 7. Write .env ────────────────────────────────────────────────
|
# ── 7. Write .env ────────────────────────────────────────────────
|
||||||
info "Creating .env..."
|
info "Creating .env..."
|
||||||
cat > "$INSTALL_DIR/.env" << ENVEOF
|
sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF
|
||||||
# ReceiptNext Configuration
|
# ReceiptNext Configuration
|
||||||
# Generated by install.sh on $(date)
|
# Generated by install.sh on $(date)
|
||||||
|
|
||||||
|
|
@ -224,27 +230,27 @@ ENVEOF
|
||||||
|
|
||||||
case "$AI_PROVIDER" in
|
case "$AI_PROVIDER" in
|
||||||
openai)
|
openai)
|
||||||
cat >> "$INSTALL_DIR/.env" << ENVEOF
|
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
|
||||||
OPENAI_API_KEY=${OPENAI_API_KEY}
|
OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
AI_MODEL=${AI_MODEL}
|
AI_MODEL=${AI_MODEL}
|
||||||
AI_BASE_URL=${AI_BASE_URL}
|
AI_BASE_URL=${AI_BASE_URL}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
;;
|
;;
|
||||||
ollama)
|
ollama)
|
||||||
cat >> "$INSTALL_DIR/.env" << ENVEOF
|
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
|
||||||
AI_MODEL=${AI_MODEL}
|
AI_MODEL=${AI_MODEL}
|
||||||
AI_BASE_URL=${AI_BASE_URL}
|
AI_BASE_URL=${AI_BASE_URL}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
;;
|
;;
|
||||||
gemini)
|
gemini)
|
||||||
cat >> "$INSTALL_DIR/.env" << ENVEOF
|
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
|
||||||
GEMINI_API_KEY=${GEMINI_API_KEY}
|
GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ -n "$SMTP_HOST" ]; then
|
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_HOST=${SMTP_HOST}
|
||||||
SMTP_PORT=${SMTP_PORT}
|
SMTP_PORT=${SMTP_PORT}
|
||||||
SMTP_USER=${SMTP_USER}
|
SMTP_USER=${SMTP_USER}
|
||||||
|
|
@ -252,12 +258,12 @@ SMTP_PASS=${SMTP_PASS}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod 600 "$INSTALL_DIR/.env"
|
sudo_if chmod 600 "$INSTALL_DIR/.env"
|
||||||
info "Configuration saved to $INSTALL_DIR/.env"
|
info "Configuration saved to $INSTALL_DIR/.env"
|
||||||
|
|
||||||
# ── 8. Systemd service ───────────────────────────────────────────
|
# ── 8. Systemd service ───────────────────────────────────────────
|
||||||
info "Creating 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]
|
[Unit]
|
||||||
Description=ReceiptNext — AI-Powered Expense Tracker
|
Description=ReceiptNext — AI-Powered Expense Tracker
|
||||||
Documentation=https://git.lohmar.co.uk/cclohmar/ReceiptNext
|
Documentation=https://git.lohmar.co.uk/cclohmar/ReceiptNext
|
||||||
|
|
@ -279,13 +285,13 @@ StandardError=append:/var/log/receiptnext.log
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
SERVEOF
|
SERVEOF
|
||||||
|
|
||||||
systemctl daemon-reload
|
sudo_if systemctl daemon-reload
|
||||||
systemctl enable "${SERVICE_NAME}"
|
sudo_if systemctl enable "${SERVICE_NAME}"
|
||||||
info "Service created: /etc/systemd/system/${SERVICE_NAME}.service"
|
info "Service created: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
# ── 9. Start ─────────────────────────────────────────────────────
|
# ── 9. Start ─────────────────────────────────────────────────────
|
||||||
info "Starting ReceiptNext..."
|
info "Starting ReceiptNext..."
|
||||||
systemctl restart "${SERVICE_NAME}" 2>/dev/null || true
|
sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -13,7 +15,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ollamaTimeout = 60 * time.Second
|
const (
|
||||||
|
ollamaTimeout = 300 * time.Second
|
||||||
|
ollamaMaxSize = 800
|
||||||
|
ollamaQuality = 60
|
||||||
|
)
|
||||||
|
|
||||||
type ollamaRequest struct {
|
type ollamaRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
|
@ -43,6 +49,15 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
|
||||||
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
|
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")
|
baseURL := os.Getenv("AI_BASE_URL")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "http://localhost:11434"
|
baseURL = "http://localhost:11434"
|
||||||
|
|
@ -51,7 +66,7 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
|
||||||
|
|
||||||
model := os.Getenv("AI_MODEL")
|
model := os.Getenv("AI_MODEL")
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "llava"
|
model = "qwen3.5:2b"
|
||||||
}
|
}
|
||||||
|
|
||||||
b64Data := base64.StdEncoding.EncodeToString(imageData)
|
b64Data := base64.StdEncoding.EncodeToString(imageData)
|
||||||
|
|
@ -61,9 +76,10 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Messages: []ollamaMessage{{
|
Messages: []ollamaMessage{{
|
||||||
Role: "user",
|
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},
|
Images: []string{b64Data},
|
||||||
}},
|
}},
|
||||||
|
Options: map[string]any{"num_predict": 256},
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
@ -100,3 +116,51 @@ func (p *ollamaProvider) ExtractReceipt(imagePath string) (*ReceiptData, error)
|
||||||
model, receipt.Merchant, receipt.Amount, receipt.Currency)
|
model, receipt.Merchant, receipt.Amount, receipt.Currency)
|
||||||
return &receipt, nil
|
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