Files
michaelswanson 860d5f55cc Initial commit
2026-06-25 19:58:40 +00:00

1076 lines
43 KiB
JavaScript
Executable File

import { assertSafeUrl, LOCAL_ALLOWED_HOSTS } from './ssrf.js';
const OLLAMA_KEEP_ALIVE = '30m';
function ensureProtocol(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return 'http://host.docker.internal:11434';
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, '');
return `http://${trimmed.replace(/\/+$/, '')}`;
}
function formatNetworkError(error, url) {
const code = error?.cause?.code || error?.code || '';
const reason = error?.cause?.message || error?.message || 'okänt fel';
const details = [];
if (code === 'UND_ERR_HEADERS_TIMEOUT' || /headers timeout/i.test(reason)) {
return `Ollama på ${url} hann inte börja svara i tid. Det brukar betyda att modellen håller på att laddas in, är för tung för nuvarande hårdvara eller att Ollama redan är upptagen. Testa igen om några sekunder eller byt till en snabbare modell för chatten.`;
}
if (/^timeout:\d+$/i.test(String(reason).trim())) {
const timeoutMs = Number(String(reason).split(':')[1]) || 0;
return `Ollama tog för lång tid på sig för ${url}${timeoutMs ? ` (${Math.round(timeoutMs / 1000)} s)` : ''}. Modellen verkar seg, kallstartar eller är upptagen just nu. Testa igen eller byt till en snabbare textmodell.`;
}
if (code) {
details.push(code);
}
if (reason && reason !== 'fetch failed') {
details.push(reason);
}
const suffix = details.length ? ` (${details.join(': ')})` : '';
return `Kunde inte ansluta till Ollama på ${url}${suffix}. Kontrollera att Ollama körs och lyssnar på nätverket, inte bara på localhost.`;
}
function isOllamaTimeoutError(error) {
const text = String(error?.message || error || '').toLowerCase();
return text.includes('tog för lång tid')
|| text.includes('headers timeout')
|| /^timeout:\d+$/i.test(String(error?.cause?.message || error?.message || '').trim());
}
const FAST_TEXT_FALLBACK_MODELS = [
'budget-snabb:latest',
'qwen2.5:3b',
'budget-ai:latest',
'phi3.5:3.8b',
'llama3.1:8b',
];
async function pickFastFallbackModel(config, currentModel) {
const models = await listOllamaModels(config);
const availableNames = new Set(models.map(model => model.name));
return FAST_TEXT_FALLBACK_MODELS.find(model => model !== currentModel && availableNames.has(model)) || null;
}
async function requestOllamaJson(config, path, options = {}) {
const baseUrl = ensureProtocol(config?.base_url);
const url = `${baseUrl}${path}`;
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 120000);
await assertSafeUrl(baseUrl, {
allowList: LOCAL_ALLOWED_HOSTS,
allowPrivateNetwork: true,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error(`timeout:${timeoutMs}`)), timeoutMs);
const fetchOptions = {
...options,
signal: controller.signal,
};
delete fetchOptions.timeoutMs;
let response;
try {
response = await fetch(url, fetchOptions);
} catch (error) {
clearTimeout(timeout);
const abortReason = String(error?.cause?.message || error?.message || '').trim();
if (error?.name === 'AbortError' || /^timeout:\d+$/i.test(abortReason)) {
throw new Error(`Ollama tog för lång tid på sig (${Math.round(timeoutMs / 1000)} s) för ${url}. Modellen verkar seg eller fastnade. Testa igen eller byt till en snabbare modell.`);
}
throw new Error(formatNetworkError(error, url));
}
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text();
throw new Error(`Ollama svarade med ${response.status} från ${url}${text ? `: ${text}` : ''}`);
}
return response.json();
}
function extractOllamaMessage(data) {
const directMessage = String(data?.message?.content || '').trim();
if (directMessage) return directMessage;
const responseMessage = String(data?.response || '').trim();
if (responseMessage) return responseMessage;
if (Array.isArray(data?.message?.content)) {
const textParts = data.message.content
.map(part => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
.trim();
if (textParts) return textParts;
}
return '';
}
async function retryEmptyTextResponse({ config, baseUrl, model, systemPrompt, message }) {
const fallbackData = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/generate',
{
method: 'POST',
timeoutMs: 45000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
stream: false,
keep_alive: OLLAMA_KEEP_ALIVE,
system: systemPrompt,
prompt: `Svara på svenska med ett komplett svar. Om frågan gäller lån ska du bedöma stabilitet, risk, månadsbelastning och total skuld utifrån underlaget.\n\nAnvändarens fråga: ${String(message || '').trim()}`,
}),
}
);
return {
message: extractOllamaMessage(fallbackData),
meta: fallbackData,
};
}
function shouldRetryImageResponse(message) {
const normalized = String(message || '').trim();
if (!normalized) return true;
const lower = normalized.toLowerCase();
return lower === 'baserat på informationen från app'
|| lower === 'baserat på informationen från appen'
|| lower.endsWith('från app')
|| lower.endsWith('från appen');
}
function isLikelyTruncatedResponse(data, message) {
const normalized = String(message || '').trim();
if (!normalized) return true;
const doneReason = String(data?.done_reason || '').toLowerCase();
if (doneReason === 'length') return true;
if (data?.done === false) return true;
if (normalized.length < 120) return false;
if (/[.!?)]["']?$/.test(normalized)) return false;
if (normalized.endsWith(':')) return true;
const lastWord = normalized.split(/\s+/).pop()?.toLowerCase() || '';
return ['och', 'att', 'om', 'för', 'med', 'utan', 'innan', 'efter', 'ut'].includes(lastWord);
}
export function ensureAiConfig(db) {
db.data.ai ||= {};
db.data.ai.ollama ||= {};
const config = db.data.ai.ollama;
config.enabled ??= false;
config.base_url = ensureProtocol(config.base_url || 'http://host.docker.internal:11434');
config.model ??= '';
config.vision_model ??= '';
config.system_prompt ??= '';
config.include_budget_context ??= true;
config.include_banking_context ??= true;
return config;
}
export function publicAiConfig(config) {
const normalized = {
enabled: !!config?.enabled,
base_url: ensureProtocol(config?.base_url || 'http://host.docker.internal:11434'),
model: config?.model || '',
vision_model: config?.vision_model || '',
system_prompt: config?.system_prompt || '',
include_budget_context: config?.include_budget_context !== false,
include_banking_context: config?.include_banking_context !== false,
};
return {
...normalized,
has_text_model: Boolean(normalized.model),
has_vision_model: Boolean(normalized.vision_model),
};
}
function buildTransactionDescription(transaction) {
return [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(Array.isArray(transaction.remittance_information) ? transaction.remittance_information : []),
].filter(Boolean).join(' ').trim() || 'Transaktion';
}
export async function suggestTransactionCategory(config, categories, transaction, context = {}) {
const baseUrl = ensureProtocol(config?.base_url);
const model = config?.model;
if (!config?.enabled || !model) {
throw new Error('Ollama är inte aktiverat med en textmodell ännu.');
}
const categoryList = categories.map(category => ({
id: category.id,
name: category.name,
color: category.color,
}));
const payload = {
model,
stream: false,
format: 'json',
messages: [
{
role: 'system',
content: [
'Du kategoriserar banktransaktioner för en svensk budgetapp.',
'Du ska prioritera användarens faktiska beteende framför generella antaganden om handlaren.',
'Exempel: OKQ8 kan vara kaffe/snacks i stället för transport om historiken tyder på det.',
'Svara endast med JSON.',
'Format: {"category_id": number|null, "new_category_name": string|null, "confidence": number, "reason": string}.',
'confidence ska vara 0 till 1.',
'Om ingen befintlig kategori passar bra får du föreslå en ny kategori i new_category_name.',
'Om du är osäker, välj den mest rimliga kategorin men säg varför kort.',
].join('\n'),
},
{
role: 'user',
content: JSON.stringify({
transaction: {
text: buildTransactionDescription(transaction),
amount: transaction.amount,
currency: transaction.currency || 'SEK',
credit_debit_indicator: transaction.credit_debit_indicator || null,
status: transaction.status || null,
booking_date: transaction.booking_date || null,
transaction_date: transaction.transaction_date || null,
},
context,
categories: categoryList,
}),
},
],
};
const data = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/chat',
{
method: 'POST',
timeoutMs: 45000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
);
const message = extractOllamaMessage(data);
let parsed = null;
try {
parsed = JSON.parse(message);
} catch {
throw new Error(`Ollama gav inget giltigt kategorisvar: ${message || 'tomt svar'}`);
}
const chosenCategory = categories.find(category => category.id === Number(parsed?.category_id)) || null;
return {
category_id: chosenCategory?.id || null,
category_name: chosenCategory?.name || null,
new_category_name: String(parsed?.new_category_name || '').trim() || null,
confidence: Number(parsed?.confidence) || 0,
reason: String(parsed?.reason || '').trim(),
model,
};
}
export async function listOllamaModels(config) {
const data = await requestOllamaJson(config, '/api/tags');
return (data?.models || []).map(model => ({
name: model.name,
size: model.size || null,
modified_at: model.modified_at || null,
digest: model.digest || null,
capabilities: Array.isArray(model.capabilities)
? model.capabilities
: Array.isArray(model.details?.capabilities)
? model.details.capabilities
: [],
}));
}
async function getOllamaModelByName(config, modelName) {
const models = await listOllamaModels(config);
return models.find(model => model.name === modelName) || null;
}
function formatCurrency(amount, currency = 'SEK') {
if (amount == null || Number.isNaN(Number(amount))) return 'okänt';
return `${Number(amount).toFixed(2)} ${currency}`;
}
function normalizeBillDueDate(dueDate, year, month) {
const raw = String(dueDate || '').trim();
if (!raw) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
if (/^\d{1,2}$/.test(raw)) {
const day = String(raw).padStart(2, '0');
return `${year}-${String(month).padStart(2, '0')}-${day}`;
}
return null;
}
function diffDays(fromDate, toDateIso) {
if (!toDateIso) return null;
const start = new Date(`${fromDate.toISOString().slice(0, 10)}T00:00:00`);
const end = new Date(`${toDateIso}T00:00:00`);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.round((end.getTime() - start.getTime()) / 86400000);
}
function getCurrentMonthSummary(db) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthEntry = db.data.months.find(item => item.year === year && item.month === month);
if (!monthEntry) {
return {
title: `${year}-${String(month).padStart(2, '0')}`,
incomeTotal: 0,
billTotal: 0,
paidTotal: 0,
remainingBudget: 0,
upcomingBills: [],
nearTermBills: [],
laterBills: [],
unknownDueBills: [],
};
}
const income = db.data.income.filter(item => item.budget_month_id === monthEntry.id);
const bills = db.data.bills.filter(item => item.budget_month_id === monthEntry.id);
const incomeTotal = income.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
const billTotal = bills.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
const paidTotal = bills.filter(item => item.is_paid).reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
const remainingBudget = incomeTotal - billTotal;
const upcomingBills = bills
.filter(item => !item.is_paid)
.sort((a, b) => String(a.due_date || '').localeCompare(String(b.due_date || '')) || a.name.localeCompare(b.name))
.map(item => ({
name: item.name,
amount: Number(item.amount) || 0,
due_date: item.due_date || null,
due_date_iso: normalizeBillDueDate(item.due_date, year, month),
}));
const nearTermBills = [];
const laterBills = [];
const unknownDueBills = [];
for (const item of upcomingBills) {
const daysUntilDue = diffDays(now, item.due_date_iso);
const enriched = {
...item,
days_until_due: daysUntilDue,
};
if (daysUntilDue == null) {
unknownDueBills.push(enriched);
} else if (daysUntilDue <= 7) {
nearTermBills.push(enriched);
} else {
laterBills.push(enriched);
}
}
return {
title: `${year}-${String(month).padStart(2, '0')}`,
incomeTotal,
billTotal,
paidTotal,
remainingBudget,
upcomingBills: upcomingBills.slice(0, 12),
nearTermBills: nearTermBills.slice(0, 8),
laterBills: laterBills.slice(0, 8),
unknownDueBills: unknownDueBills.slice(0, 6),
};
}
function getSubscriptionSummary(db) {
const active = (db.data.subscriptions || []).filter(item => item.is_active);
const total = active.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
return {
count: active.length,
total,
items: active.slice(0, 10).map(item => ({
name: item.name,
amount: Number(item.amount) || 0,
currency: item.original_currency || 'SEK',
original_amount: item.original_amount ?? null,
})),
};
}
function getLoanSummary(db) {
const active = (db.data.loans || [])
.filter(item => item.is_active)
.map(item => ({
id: item.id,
name: item.name || 'Namnlöst lån',
current_balance: Number(item.current_balance) || 0,
monthly_payment: Number(item.monthly_payment) || 0,
interest_rate: item.interest_rate == null || item.interest_rate === ''
? null
: Number(item.interest_rate),
}));
const recentPayments = (db.data.payments || [])
.filter(item => active.some(loan => loan.id === item.loan_id))
.sort((a, b) => String(b.payment_date || '').localeCompare(String(a.payment_date || '')))
.slice(0, 6)
.map(item => {
const loan = active.find(entry => entry.id === item.loan_id);
return {
loan_name: loan?.name || 'Lån',
amount: Number(item.amount) || 0,
payment_date: item.payment_date || null,
};
});
return {
count: active.length,
total_balance: active.reduce((sum, item) => sum + item.current_balance, 0),
total_monthly_payment: active.reduce((sum, item) => sum + item.monthly_payment, 0),
items: active.slice(0, 8),
recent_payments: recentPayments,
};
}
function getAppSettings(db) {
const settings = db.data.app_settings || {};
return {
finance_profile: {
salary_day_of_month: settings.finance_profile?.salary_day_of_month ?? 25,
buffer_days_target: settings.finance_profile?.buffer_days_target ?? 7,
salary_account_uid: settings.finance_profile?.salary_account_uid || '',
recurring_income_note: settings.finance_profile?.recurring_income_note || '',
},
account_view: {
primary_account_uid: settings.account_view?.primary_account_uid || '',
include_savings_in_ai: settings.account_view?.include_savings_in_ai === true,
hide_zero_balance_accounts: settings.account_view?.hide_zero_balance_accounts === true,
},
};
}
function getPreferredBalance(account) {
const balances = Array.isArray(account?.balances) ? account.balances : [];
return balances.find(item => item.balance_type?.toLowerCase().includes('interimavailable'))
|| balances.find(item => item.balance_type?.toLowerCase().includes('expected'))
|| balances[0]
|| null;
}
function getAccountName(account) {
return account?.display_name || account?.alias || account?.name || 'Konto';
}
function looksLikeSavingsAccount(account) {
const label = [
account?.display_name,
account?.alias,
account?.name,
].filter(Boolean).join(' ').toLowerCase();
return /\bspar|sparkonto|savings|buffer\b/.test(label);
}
function shouldIncludeAccountInAi(account, appSettings) {
const pinnedAccounts = [
appSettings.account_view.primary_account_uid,
appSettings.finance_profile.salary_account_uid,
].filter(Boolean);
if (pinnedAccounts.includes(account.uid)) {
return true;
}
if (appSettings.account_view.include_savings_in_ai) {
return true;
}
return !looksLikeSavingsAccount(account);
}
function getSavingsReserveSummary(db) {
const config = db.data.banking?.enable_banking || {};
const accounts = Array.isArray(config.accounts) ? config.accounts : [];
const savingsAccounts = accounts.filter(looksLikeSavingsAccount);
const items = savingsAccounts.map(account => {
const preferredBalance = getPreferredBalance(account);
return {
uid: account.uid,
name: getAccountName(account),
currency: account.currency || preferredBalance?.balance_amount?.currency || 'SEK',
balance: preferredBalance?.balance_amount?.amount != null
? Number(preferredBalance.balance_amount.amount)
: null,
};
}).filter(account => account.balance != null);
return {
count: items.length,
total_balance: items.reduce((sum, item) => sum + (Number(item.balance) || 0), 0),
items,
};
}
function getBankingSummary(db) {
const appSettings = getAppSettings(db);
const config = db.data.banking?.enable_banking || {};
const accounts = Array.isArray(config.accounts) ? config.accounts : [];
const transactions = Array.isArray(config.transactions) ? config.transactions : [];
const visibleAccounts = accounts.filter(account => shouldIncludeAccountInAi(account, appSettings));
const fallbackAccounts = visibleAccounts.length > 0 ? visibleAccounts : accounts;
const accountSummaries = fallbackAccounts.slice(0, 10).map(account => {
const preferredBalance = getPreferredBalance(account);
return {
uid: account.uid,
name: getAccountName(account),
currency: account.currency || preferredBalance?.balance_amount?.currency || 'SEK',
balance: preferredBalance?.balance_amount?.amount != null
? Number(preferredBalance.balance_amount.amount)
: null,
iban: account.iban || null,
last_synced_at: account.last_synced_at || null,
};
});
const seenRecentTransactions = new Set();
const recentTransactions = [];
for (const item of transactions) {
const normalized = {
booking_date: item.booking_date || item.transaction_date || null,
amount: item.amount != null ? Number(item.amount) : null,
currency: item.currency || 'SEK',
text: item.creditor_name || item.debtor_name || item.note || item.remittance_information?.[0] || 'Transaktion',
};
const dedupeKey = [
normalized.booking_date || '',
normalized.amount ?? '',
normalized.currency || '',
normalized.text || '',
].join('|');
if (seenRecentTransactions.has(dedupeKey)) continue;
seenRecentTransactions.add(dedupeKey);
recentTransactions.push(normalized);
if (recentTransactions.length >= 20) break;
}
return {
enabled: !!config.enabled,
institution: config.institution || '',
session_active: Boolean(config.session_id),
last_sync_at: config.last_sync_at || null,
account_count: accounts.length,
visible_account_count: fallbackAccounts.length,
transaction_count: transactions.length,
accounts: accountSummaries,
recent_transactions: recentTransactions,
};
}
function buildLiquiditySummary(db) {
const month = getCurrentMonthSummary(db);
const banking = getBankingSummary(db);
const appSettings = getAppSettings(db);
const preferredIds = [
appSettings.account_view.primary_account_uid,
appSettings.finance_profile.salary_account_uid,
].filter(Boolean);
const primaryAccount = banking.accounts.find(account => preferredIds.includes(account.uid) && account.balance != null)
|| banking.accounts.find(account => preferredIds.includes(account.uid))
|| banking.accounts.find(account => account.balance != null)
|| banking.accounts[0]
|| null;
return {
account_name: primaryAccount?.name || 'Huvudkonto',
account_uid: primaryAccount?.uid || '',
available_balance: primaryAccount?.balance ?? null,
currency: primaryAccount?.currency || 'SEK',
unpaid_bills_total: month.upcomingBills.reduce((sum, item) => sum + (Number(item.amount) || 0), 0),
unpaid_bills_count: month.upcomingBills.length,
near_term_bills_total: month.nearTermBills.reduce((sum, item) => sum + (Number(item.amount) || 0), 0),
near_term_bills_count: month.nearTermBills.length,
};
}
function buildContextMeta(db, config) {
const month = getCurrentMonthSummary(db);
const subscriptions = getSubscriptionSummary(db);
const loans = getLoanSummary(db);
const banking = getBankingSummary(db);
const liquidity = buildLiquiditySummary(db);
const savingsReserve = getSavingsReserveSummary(db);
const appSettings = getAppSettings(db);
const sources = [];
if (config.include_banking_context !== false) {
sources.push({
key: 'banking',
label: 'Enable Banking',
status: 'included',
summary: liquidity.available_balance != null
? `Likviditetsfokus: ${liquidity.account_name} har ${formatCurrency(liquidity.available_balance, liquidity.currency)} tillgängligt just nu.`
: `Likviditetsfokus: ${banking.account_count} konton synkade, men aktuellt saldo saknas på huvudkontot.`,
});
sources.push({
key: 'savings',
label: 'Sparkonton',
status: savingsReserve.count > 0 ? 'included' : 'missing',
summary: savingsReserve.count > 0
? `${savingsReserve.count} sparkonto(n) finns som möjlig reserv, totalt ${formatCurrency(savingsReserve.total_balance)}.`
: 'Inget sparkonto med känt saldo hittades som reserv.',
});
}
if (loans.count > 0) {
sources.push({
key: 'loans',
label: 'Lån',
status: 'included',
summary: `${loans.count} aktiva lån med total skuld ${formatCurrency(loans.total_balance)} och månadsbetalningar ${formatCurrency(loans.total_monthly_payment)}.`,
});
}
if (config.include_budget_context !== false) {
sources.push({
key: 'budget',
label: 'Budget',
status: 'included',
summary: `Budget för ${month.title} med inkomster ${formatCurrency(month.incomeTotal)} och planerade räkningar ${formatCurrency(month.billTotal)}. Budgeten är teoretisk och används bara som stöd.`,
});
sources.push({
key: 'subscriptions',
label: 'Prenumerationer',
status: 'included',
summary: `${subscriptions.count} aktiva prenumerationer, totalt ${formatCurrency(subscriptions.total)} per månad.`,
});
} else {
sources.push({
key: 'budget',
label: 'Budget',
status: 'excluded',
summary: 'Budgetdata skickades inte till modellen.',
});
}
if (config.include_banking_context !== false) {
sources.push({
key: 'banking',
label: 'Enable Banking',
status: banking.account_count > 0 ? 'included' : 'missing',
summary: banking.account_count > 0
? `${banking.visible_account_count} av ${banking.account_count} konton används i AI-underlaget och ${banking.transaction_count} transaktioner finns tillgängliga.`
: 'Ingen synkad bankdata fanns tillgänglig, så modellen fick ingen faktisk saldoinformation.',
});
} else {
sources.push({
key: 'banking',
label: 'Enable Banking',
status: 'excluded',
summary: 'Bankdata skickades inte till modellen.',
});
}
sources.push({
key: 'profile',
label: 'Löneprofil',
status: 'included',
summary: `Lönedag runt dag ${appSettings.finance_profile.salary_day_of_month} varje månad, buffertmål ${appSettings.finance_profile.buffer_days_target} dagar och primärt konto ${liquidity.account_name}.`,
});
return {
sources,
method: [
'Appen bygger först ett likviditetsunderlag från valt primärkonto och övrig bankdata enligt kontovisningsinställningarna.',
'Därefter kompletteras underlaget med lån, budget, prenumerationer och din sparade löneprofil.',
'Det underlaget skickas som systemkontext till Ollama tillsammans med din fråga och eventuella bilder.',
'Om bankdata saknas blir slutsatsen teoretisk och baseras bara på budgeterade siffror.',
'Modellen gör ingen egen bankkoppling utan resonerar endast utifrån det underlag appen faktiskt skickar in.',
],
};
}
function buildContextText(db, config) {
const blocks = [];
const loans = getLoanSummary(db);
const appSettings = getAppSettings(db);
const savingsReserve = getSavingsReserveSummary(db);
if (config.include_banking_context !== false) {
const banking = getBankingSummary(db);
const liquidity = buildLiquiditySummary(db);
blocks.push([
'Likviditetsfokus:',
`Dagens datum: ${new Date().toISOString().slice(0, 10)}`,
`Enable Banking aktivt: ${banking.enabled ? 'ja' : 'nej'}`,
`Bank: ${banking.institution || 'ingen vald'}`,
`Aktiv session: ${banking.session_active ? 'ja' : 'nej'}`,
`Senaste sync: ${banking.last_sync_at || 'ingen'}`,
`Primärt konto enligt inställningar: ${liquidity.account_name}${liquidity.account_uid ? ` (${liquidity.account_uid})` : ''}`,
liquidity.available_balance != null
? `Primärt saldo att utgå från: ${liquidity.account_name} ${formatCurrency(liquidity.available_balance, liquidity.currency)}`
: `Primärt saldo att utgå från: okänt`,
`Löneprofil: lönedag cirka dag ${appSettings.finance_profile.salary_day_of_month} varje månad, buffertmål ${appSettings.finance_profile.buffer_days_target} dagar`,
appSettings.finance_profile.salary_account_uid
? `Lönekonto valt i appen: ${appSettings.finance_profile.salary_account_uid}`
: 'Lönekonto valt i appen: inget särskilt valt',
appSettings.finance_profile.recurring_income_note
? `Återkommande inkomst/notis: ${appSettings.finance_profile.recurring_income_note}`
: 'Återkommande inkomst/notis: ingen extra anteckning',
`Sparkonton i AI-underlaget: ${appSettings.account_view.include_savings_in_ai ? 'ja' : 'nej'}`,
savingsReserve.count > 0
? `Möjlig reserv i sparkonton: ${formatCurrency(savingsReserve.total_balance)} totalt (${savingsReserve.items.map(item => `${item.name} ${formatCurrency(item.balance, item.currency)}`).join(', ')})`
: 'Möjlig reserv i sparkonton: ingen med känt saldo',
`Räkningar nära i tid (inom 7 dagar): ${formatCurrency(liquidity.near_term_bills_total)} (${liquidity.near_term_bills_count} st). Det här är bara kompletterande riskkontext om användaren själv frågar om hur pengarna ska räcka framåt.`,
`Övriga planerade/obetalda räkningar i appen: ${formatCurrency(liquidity.unpaid_bills_total)} totalt (${liquidity.unpaid_bills_count} st). Dessa är planeringskontext och ska normalt ignoreras i rena "har jag råd just nu?"-frågor.`,
banking.accounts.length
? `Konton: ${banking.accounts.map(account => `${account.name}: ${formatCurrency(account.balance, account.currency)}`).join(', ')}`
: 'Konton: inga synkade ännu',
banking.recent_transactions.length
? `Senaste transaktioner: ${banking.recent_transactions.slice(0, 8).map(item => `${item.booking_date || 'okänt datum'} ${item.text} ${formatCurrency(item.amount, item.currency)}`).join('; ')}`
: 'Senaste transaktioner: inga synkade ännu',
].join('\n'));
}
if (loans.count > 0) {
blocks.push([
'Lånebild:',
`Aktiva lån: ${loans.count}`,
`Total återstående skuld: ${formatCurrency(loans.total_balance)}`,
`Totala månadsbetalningar: ${formatCurrency(loans.total_monthly_payment)}`,
`Lån i appen: ${loans.items.map(item => {
const parts = [
item.name,
`saldo ${formatCurrency(item.current_balance)}`,
];
if (item.monthly_payment > 0) {
parts.push(`månadsbetalning ${formatCurrency(item.monthly_payment)}`);
}
if (Number.isFinite(item.interest_rate)) {
parts.push(`ränta ${item.interest_rate}%`);
}
return parts.join(', ');
}).join('; ')}`,
loans.recent_payments.length
? `Senaste lånebetalningar: ${loans.recent_payments.map(item => `${item.payment_date || 'okänt datum'} ${item.loan_name} ${formatCurrency(item.amount)}`).join('; ')}`
: 'Senaste lånebetalningar: inga registrerade',
].join('\n'));
}
if (config.include_budget_context !== false) {
const month = getCurrentMonthSummary(db);
const subscriptions = getSubscriptionSummary(db);
blocks.push([
'Teoretisk budgetkontext:',
`Budgetmånad: ${month.title}`,
`Totala inkomster: ${formatCurrency(month.incomeTotal)}`,
`Totala räkningar: ${formatCurrency(month.billTotal)}`,
`Betalat hittills: ${formatCurrency(month.paidTotal)}`,
`Kvar efter planerade räkningar: ${formatCurrency(month.remainingBudget)} (teoretiskt månadsvärde, inte kontantsaldo)`,
`Aktiva prenumerationer: ${subscriptions.count} st, totalt ${formatCurrency(subscriptions.total)}`,
month.nearTermBills.length
? `Räkningar nära i tid: ${month.nearTermBills.map(item => `${item.name} ${formatCurrency(item.amount)}${item.due_date_iso ? ` (förfallo ${item.due_date_iso})` : ''}`).join(', ')}`
: 'Räkningar nära i tid: inga registrerade',
month.laterBills.length
? `Räkningar senare i perioden: ${month.laterBills.map(item => `${item.name} ${formatCurrency(item.amount)}${item.due_date_iso ? ` (förfallo ${item.due_date_iso})` : ''}`).join(', ')}`
: 'Räkningar senare i perioden: inga registrerade',
month.unknownDueBills.length
? `Räkningar utan tydligt förfallodatum: ${month.unknownDueBills.map(item => `${item.name} ${formatCurrency(item.amount)}`).join(', ')}`
: 'Räkningar utan tydligt förfallodatum: inga registrerade',
].join('\n'));
}
return blocks.join('\n\n');
}
function buildCompactContextText(db, config) {
const appSettings = getAppSettings(db);
const banking = getBankingSummary(db);
const liquidity = buildLiquiditySummary(db);
const loans = getLoanSummary(db);
const month = getCurrentMonthSummary(db);
const subscriptions = getSubscriptionSummary(db);
const savingsReserve = getSavingsReserveSummary(db);
const lines = [
'Kompakt ekonomisk kontext:',
`Datum: ${new Date().toISOString().slice(0, 10)}`,
liquidity.available_balance != null
? `Primärt saldo: ${liquidity.account_name} ${formatCurrency(liquidity.available_balance, liquidity.currency)}`
: 'Primärt saldo: okänt',
`Obetalda räkningar i appen: ${formatCurrency(liquidity.unpaid_bills_total)} (${liquidity.unpaid_bills_count} st)`,
appSettings.finance_profile.recurring_income_note
? `Återkommande inkomst/notis: ${appSettings.finance_profile.recurring_income_note}`
: 'Återkommande inkomst/notis: ingen',
savingsReserve.count > 0
? `Sparkontoreserv: ${formatCurrency(savingsReserve.total_balance)}`
: 'Sparkontoreserv: ingen',
];
if (config.include_banking_context !== false && banking.recent_transactions.length) {
lines.push(`Senaste transaktioner: ${banking.recent_transactions.slice(0, 4).map(item => `${item.booking_date || 'okänt datum'} ${item.text} ${formatCurrency(item.amount, item.currency)}`).join('; ')}`);
}
if (loans.count > 0) {
lines.push(`Lån: ${loans.count} aktiva, total skuld ${formatCurrency(loans.total_balance)}, månadsbetalningar ${formatCurrency(loans.total_monthly_payment)}`);
}
if (config.include_budget_context !== false) {
lines.push(`Budgetstöd: inkomster ${formatCurrency(month.incomeTotal)}, räkningar ${formatCurrency(month.billTotal)}, prenumerationer ${formatCurrency(subscriptions.total)}/månad`);
lines.push(`Räkningar nära i tid (inom 7 dagar): ${formatCurrency(liquidity.near_term_bills_total)} (${liquidity.near_term_bills_count} st). Använd bara detta om användaren faktiskt frågar om kommande räkningar, pengar fram till nästa inkomst eller total riskbild.`);
}
return lines.join('\n');
}
function buildSystemPrompt(config, contextText) {
return [
'Du är Enkelbudgets AI-assistent och svarar på svenska.',
'Var tydlig, konkret och försiktig med ekonomiska råd.',
'Om användaren frågar om de har råd med något ska du i första hand utgå från faktiskt tillgängligt kontosaldo, användarens egna uppgifter om dagar kvar till lön eller annan inkomst, samt omedelbara utgifter de nämner.',
'Standardläget är "här och nu": om användaren bara frågar om det finns utrymme för ett köp ska du svara utifrån nuvarande saldo och det användaren själv nämner, inte utifrån alla framtida räkningar i appen.',
'När lån finns i underlaget ska du väga in återstående skuld, månatliga lånebetalningar och andra kända åtaganden innan du bedömer om ett köp är klokt.',
'Budgeten i appen är sekundär och teoretisk. Använd den bara som stöd, aldrig som huvudskäl för att säga att användaren har råd.',
'Om bankdata och budget säger olika saker ska bankdata väga tyngst.',
'Om användaren själv anger att en inkomst kommer ett visst datum ska du väga in det datumet uttryckligen i resonemanget.',
'Kommande räkningar, framtida förfallodatum och planerade utgifter ska bara vägas in tungt om användaren uttryckligen frågar om "kommer pengarna räcka", "fram till lön", "efter räkningar", "den här veckan/månaden" eller liknande.',
'När du räknar på ett köp måste du alltid visa delposterna rad för rad, sedan en separat totalsumma, och därefter kvarvarande saldo.',
'När användaren frågar "har jag råd" eller "finns det utrymme" ska du räkna exakt så här om siffror finns: nuvarande saldo minus köp = kvar nu. Lägg sedan till nästa uttryckligen nämnda inkomst och visa nytt saldo efter inkomsten.',
'Om användaren nämner när inkomsten kommer ska du också räkna ut ungefärlig budget per dag fram till inkomsten. Om sparkonto finns i underlaget ska du, när det är relevant, visa två varianter: utan att röra sparkontot och med sparkontot som reserv.',
'Var strikt med matematik och skriv aldrig fel totalsumma. Om 2548.17 - 1749.30 = 798.87 ska just 798.87 stå i svaret.',
'Du måste kontrollera att totalsumman exakt matchar delposterna innan du skriver slutsatsen. Om 600 + 500 nämns ska totalsumman vara 1100, inte något annat.',
'Använd gärna detta upplägg vid köpråd: 1) Vad bilden visar, 2) Uträkning, 3) Kvar efter köp, 4) Risk före nästa inkomst, 5) Bedömning.',
'Behandla inte alla registrerade räkningar som akuta. Räkningar med senare förfallodatum är planeringskontext, inte omedelbara stoppklossar.',
'Om en räkning ligger efter nästa kända inkomst eller senare i perioden ska den nämnas som framtida åtagande, inte dras av som om den måste betalas idag.',
'Om data saknas ska du säga det rakt ut istället för att gissa.',
config.system_prompt?.trim() || '',
'Ekonomisk kontext från appen:',
contextText,
].filter(Boolean).join('\n\n');
}
export async function chatWithOllama(config, db, { message, images = [], conversation = [] }) {
const baseUrl = ensureProtocol(config?.base_url);
const hasImages = images.length > 0;
let model = hasImages
? (config.vision_model || config.model)
: config.model;
if (!model) {
throw new Error(hasImages
? 'Ingen vision-modell vald för bildunderlag'
: 'Ingen textmodell vald för Ollama');
}
if (hasImages) {
const selectedModel = await getOllamaModelByName({ ...config, base_url: baseUrl }, model);
const hasVisionCapability = selectedModel?.capabilities?.includes('vision');
if (!hasVisionCapability) {
throw new Error(`Modellen ${model} verkar inte ha bildstöd. Välj en vision-modell i Inställningar, till exempel qwen3.5:9b.`);
}
}
const contextPreview = buildContextText(db, config);
const compactContextPreview = buildCompactContextText(db, config);
const contextMeta = buildContextMeta(db, config);
const systemPrompt = buildSystemPrompt(config, contextPreview);
const compactSystemPrompt = buildSystemPrompt(config, compactContextPreview);
const historyMessages = conversation
.slice(-6)
.filter(entry => entry?.role === 'user' || entry?.role === 'assistant')
.map(entry => ({
role: entry.role,
content: String(entry.content || '').trim(),
}))
.filter(entry => entry.content);
const payload = {
model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
...historyMessages,
{
role: 'user',
content: hasImages
? `Du har fått ${images.length} bifogad bild${images.length === 1 ? '' : 'er'}. Titta först noggrant på bilden/bilderna, identifiera produkt, tjänst, pris och annan relevant information som faktiskt syns innan du svarar på användarens fråga.\n\nAnvändarens fråga: ${String(message || '').trim()}`
: String(message || '').trim(),
...(hasImages ? { images: images.map(image => image.data_base64) } : {}),
},
],
keep_alive: OLLAMA_KEEP_ALIVE,
};
let data;
try {
data = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/chat',
{
method: 'POST',
timeoutMs: hasImages ? 180000 : 180000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
);
} catch (error) {
if (!hasImages && isOllamaTimeoutError(error)) {
const fallbackModel = await pickFastFallbackModel(
{ ...config, base_url: baseUrl },
model
);
data = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/generate',
{
method: 'POST',
timeoutMs: 90000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: fallbackModel || model,
stream: false,
keep_alive: OLLAMA_KEEP_ALIVE,
system: compactSystemPrompt,
prompt: `Svara på svenska med ett kort, konkret men komplett svar på den här frågan. Utgå främst från likviditet, när nästa inkomst kommer och eventuella omedelbara utgifter.\n\nAnvändarens fråga: ${String(message || '').trim()}`,
}),
}
);
model = fallbackModel || model;
} else {
throw error;
}
}
let assistantMessage = extractOllamaMessage(data);
let completionMeta = data;
if (hasImages && shouldRetryImageResponse(assistantMessage)) {
const initialAssistantMessage = assistantMessage;
const fallbackPayload = {
model,
stream: false,
keep_alive: OLLAMA_KEEP_ALIVE,
system: systemPrompt,
prompt: `Du har fått ${images.length} bifogad bild${images.length === 1 ? '' : 'er'}. Läs först av vad som faktiskt syns i bilden. Du måste uttryckligen identifiera: 1) produkt eller tjänst, 2) pris, 3) valuta, 4) eventuell tidsperiod eller abonnemangslängd. Svara sedan på svenska och koppla svaret till användarens ekonomi. Om pris eller produkt inte går att läsa måste du säga det tydligt.\n\nAnvändarens fråga: ${String(message || '').trim()}`,
images: images.map(image => image.data_base64),
};
try {
const fallbackData = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/generate',
{
method: 'POST',
timeoutMs: 120000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fallbackPayload),
}
);
const fallbackMessage = extractOllamaMessage(fallbackData);
if (fallbackMessage) {
assistantMessage = fallbackMessage;
completionMeta = fallbackData;
} else {
assistantMessage = initialAssistantMessage;
}
} catch {
assistantMessage = initialAssistantMessage;
}
}
if (assistantMessage && isLikelyTruncatedResponse(completionMeta, assistantMessage)) {
try {
const continuationData = await requestOllamaJson(
{ ...config, base_url: baseUrl },
'/api/chat',
{
method: 'POST',
timeoutMs: hasImages ? 120000 : 60000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
stream: false,
keep_alive: OLLAMA_KEEP_ALIVE,
messages: [
{ role: 'system', content: systemPrompt },
...historyMessages,
{
role: 'user',
content: hasImages
? `Du har fått ${images.length} bifogad bild${images.length === 1 ? '' : 'er'}. Titta först noggrant på bilden/bilderna, identifiera produkt, tjänst, pris och annan relevant information som faktiskt syns innan du svarar på användarens fråga.\n\nAnvändarens fråga: ${String(message || '').trim()}`
: String(message || '').trim(),
...(hasImages ? { images: images.map(image => image.data_base64) } : {}),
},
{ role: 'assistant', content: assistantMessage },
{ role: 'user', content: 'Fortsätt exakt där du slutade. Upprepa inte början. Avsluta svaret komplett med slutsats.' },
],
}),
}
);
const continuationMessage = extractOllamaMessage(continuationData);
if (continuationMessage) {
assistantMessage = `${assistantMessage}${/\s$/.test(assistantMessage) ? '' : '\n\n'}${continuationMessage}`.trim();
completionMeta = continuationData;
}
} catch {
// Behåll första svaret om fortsättningen misslyckas.
}
}
if (!assistantMessage && !hasImages) {
try {
const fallback = await retryEmptyTextResponse({
config,
baseUrl,
model,
systemPrompt,
message,
});
if (fallback.message) {
assistantMessage = fallback.message;
completionMeta = fallback.meta;
}
} catch {
// Behåll originalfelet längre ned om fallbacken också ger tomt svar.
}
}
if (!assistantMessage) {
throw new Error(hasImages
? `Ollama-modellen ${model} svarade utan text på bildförfrågan. Testa igen eller byt vision-modell.`
: `Ollama-modellen ${model} svarade utan text.`);
}
return {
model,
message: assistantMessage,
created_at: data?.created_at || new Date().toISOString(),
context_preview: contextPreview,
context_meta: contextMeta,
};
}