Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions templates/meme-search/assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added templates/meme-search/assets/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
177 changes: 177 additions & 0 deletions templates/meme-search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { Output, randomPassword, Services } from "~templates-utils";
import { Input } from "./meta";

export function generate(input: Input): Output {
const services: Services = [];
const databasePassword = randomPassword();
const useLocalGenerator = input.imageDescriptionProvider === "local";

const volumesBasePath = `/etc/easypanel/projects/$(PROJECT_NAME)/${input.appServiceName}/volumes`;

// Rails' DATABASE_URL parsing goes through ActiveRecord's
// ConnectionUrlResolver, which forces Ruby's strict, legacy
// URI::RFC2396_Parser. That parser rejects underscores in hostnames,
// but Easypanel's internal service hostnames always take the form
// $(PROJECT_NAME)_<serviceName>, which contains one. A discrete
// host/port/database.yml (mounted below) avoids URL parsing entirely.
const databaseYaml = `test:
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
database: meme_test

development:
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
database: meme_development

production:
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
host: <%= ENV["DATABASE_HOST"] %>
port: <%= ENV["DATABASE_PORT"] %>
database: <%= ENV["DATABASE_NAME"] %>
username: <%= ENV["DATABASE_USER"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>
`;

const sharedEnv = [
`DATABASE_HOST=$(PROJECT_NAME)_${input.appServiceName}-db`,
`DATABASE_PORT=5432`,
`DATABASE_NAME=$(PROEJCT_NAME)`,
`DATABASE_USER=postgres`,
`DATABASE_PASSWORD=${databasePassword}`,
`IMAGE_DESCRIPTION_PROVIDER=${input.imageDescriptionProvider}`,
`OPENAI_API_BASE_URL=${input.openaiApiBaseUrl ?? ""}`,
`OPENAI_API_KEY=${input.openaiApiKey ?? ""}`,
`OPENAI_VISION_MODEL=${input.openaiVisionModel ?? ""}`,
];

services.push({
type: "app",
data: {
serviceName: input.appServiceName,
source: {
type: "image",
image: input.webImage,
},
env: sharedEnv.join("\n"),
domains: [
{
host: "$(EASYPANEL_DOMAIN)",
port: 3000,
},
],
mounts: [
{
type: "file",
content: databaseYaml,
mountPath: "/rails/config/database.yml",
},
{
type: "volume",
name: "memes",
mountPath: "/rails/public/memes",
},
{
type: "volume",
name: "direct-uploads",
mountPath: "/rails/public/memes/direct-uploads",
},
],
},
});

services.push({
type: "app",
data: {
serviceName: `${input.appServiceName}-jobs`,
source: {
type: "image",
image: input.webImage,
},
env: sharedEnv.join("\n"),
deploy: {
command: "./bin/jobs",
},
mounts: [
{
type: "file",
content: databaseYaml,
mountPath: "/rails/config/database.yml",
},
{
type: "bind",
hostPath: `${volumesBasePath}/memes`,
mountPath: "/rails/public/memes",
},
{
type: "bind",
hostPath: `${volumesBasePath}/direct-uploads`,
mountPath: "/rails/public/memes/direct-uploads",
},
],
},
});

if (useLocalGenerator) {
services.push({
type: "app",
data: {
serviceName: `${input.appServiceName}-generator`,
source: {
type: "image",
image: input.generatorImage,
},
env: [
`APP_PORT=3000`,
`GEN_URL=http://$(PROJECT_NAME)_${input.appServiceName}:3000`,
].join("\n"),
mounts: [
{
type: "bind",
hostPath: `${volumesBasePath}/memes`,
mountPath: "/app/public/memes",
},
{
type: "bind",
hostPath: `${volumesBasePath}/direct-uploads`,
mountPath: "/app/public/memes/direct-uploads",
},
{
type: "volume",
name: "generator-db",
mountPath: "/app/db",
},
{
type: "volume",
name: "models",
mountPath: "/root/.cache/huggingface",
},
],
resources: {
memoryReservation: 0,
memoryLimit: 12288,
cpuReservation: 0,
cpuLimit: 0,
},
},
});
}

services.push({
type: "postgres",
data: {
serviceName: `${input.appServiceName}-db`,
password: databasePassword,
image: "pgvector/pgvector:pg17",
},
});

return { services };
}
113 changes: 113 additions & 0 deletions templates/meme-search/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Meme Search
description:
Meme Search is a self-hosted app for indexing and searching your local meme
collection with natural language instead of scrolling through a camera roll.
Every image gets an AI-generated description, embedded into PostgreSQL with
pgvector, and made searchable by meaning rather than filename. Image
descriptions can be generated locally with a bundled vision model or offloaded
to any OpenAI-compatible API.
instructions: |
Open the app URL and drag memes into the direct-upload area, or mount
your own meme folders into the web and jobs services under
/rails/public/memes to bulk-import an existing collection. New uploads
are picked up by the background jobs service, described by the local
vision model (or your configured OpenAI-compatible API), and embedded
into Postgres automatically, after which they become searchable from the
home page.
changeLog:
- date: 2026-07-13
description: first release
links:
- label: GitHub
url: https://github.com/neonwatty/meme-search
- label: Documentation
url: https://github.com/neonwatty/meme-search/blob/main/README.md
contributors:
- name: Ahson Shaikh
url: https://github.com/Ahson-Shaikh
schema:
type: object
required:
- appServiceName
- webImage
- generatorImage
- imageDescriptionProvider
properties:
appServiceName:
type: string
title: App Service Name
default: meme-search
webImage:
type: string
title: Web/Jobs Image
default: ghcr.io/neonwatty/meme_search:v2.2.0
generatorImage:
type: string
title: Local Vision Model Image
default: ghcr.io/neonwatty/image_to_text_generator:v2.2.0
imageDescriptionProvider:
type: string
title: Image Description Provider
default: local
oneOf:
- enum:
- local
title: Local (bundled vision model, no API key needed)
- enum:
- openai
title: OpenAI-compatible API
openaiApiBaseUrl:
type: string
title: OpenAI-Compatible API Base URL
description:
Only used when Image Description Provider is set to OpenAI-compatible
API.
openaiApiKey:
type: string
title: OpenAI-Compatible API Key
description:
Only used when Image Description Provider is set to OpenAI-compatible
API.
openaiVisionModel:
type: string
title: OpenAI-Compatible Vision Model
description:
Only used when Image Description Provider is set to OpenAI-compatible
API.
benefits:
- title: Search by Meaning, Not Filename
description:
Every meme is described by a vision model and embedded with pgvector, so a
search for "cat looking confused" finds it even if the file is named
IMG_4213.jpg.
- title: No External API Required
description:
The bundled local vision model generates image descriptions on your own
hardware by default, so nothing leaves your server unless you choose to
switch to an OpenAI-compatible API.
- title: Bulk Import Existing Collections
description:
Mount an existing folder of memes straight into the container to index a
whole collection at once, instead of uploading files one by one.
features:
- title: Drag-and-Drop Uploads
description:
Drop new memes directly into the browser and they are picked up by the
background jobs service automatically.
- title: Natural Language Search
description:
Search results are ranked by semantic similarity to your query using
pgvector, not just keyword matching.
- title: Pluggable Vision Backend
description:
Switch between the bundled local model and any OpenAI-compatible vision
API by changing a single environment variable.
- title: Rescan and Re-Index
description:
Trigger a rescan from the UI to pick up new files added to a mounted meme
directory or regenerate descriptions after switching providers.
tags:
- Media
- Search
- AI
- Self-Hosted