Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

View File

@@ -1,7 +1,9 @@
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
const OPENAI_SCAN_MODEL_PRO = (process.env.OPENAI_SCAN_MODEL_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4o-mini').trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.OPENAI_SCAN_FALLBACK_MODELS_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
const OPENAI_TIMEOUT_MS = (() => {
@@ -22,8 +24,13 @@ const parseModelChain = (primaryModel, fallbackModels) => {
};
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
const getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
@@ -197,12 +204,17 @@ const buildIdentifyPrompt = (language, mode) => {
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
: 'Identify the most likely houseplant species from this image with conservative confidence.';
const nameLanguageInstruction = language === 'en'
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
return [
`${reviewInstruction}`,
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
`- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.',
@@ -324,10 +336,11 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
return { payload: null, modelUsed: null, attemptedModels };
};
const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
const modelChain = getScanModelChain(plan);
const completion = await postChatCompletion({
modelChain: OPENAI_SCAN_MODEL_CHAIN,
modelChain,
imageUri,
messages: [
{
@@ -355,7 +368,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI identify returned empty content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
@@ -365,7 +378,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
@@ -375,7 +388,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
@@ -439,8 +452,10 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
module.exports = {
analyzePlantHealth,
buildIdentifyPrompt,
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0],
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
identifyPlant,
isConfigured: () => Boolean(OPENAI_API_KEY),
normalizeIdentifyResult,
};