Files
client/www/js/ai-chat.mjs
T
2026-04-17 16:52:51 -04:00

144 lines
4.3 KiB
JavaScript

const AI_CONFIG_KEY = 'ai_chat_openai_config_v1';
function normalizeBaseUrl(url) {
const raw = String(url || '').trim();
if (!raw) return '';
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
function safeJsonParse(value, fallback = null) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
export function loadAiConfigLocal() {
const defaults = {
provider: 'ppq.ai',
base_url: 'https://api.ppq.ai',
api_key: '',
model: 'claude-haiku-4.5',
max_tokens: 4096,
temperature: 0.2,
};
const raw = localStorage.getItem(AI_CONFIG_KEY);
const parsed = raw ? safeJsonParse(raw, null) : null;
if (!parsed || typeof parsed !== 'object') return defaults;
return {
provider: String(parsed.provider || defaults.provider),
base_url: String(parsed.base_url || defaults.base_url),
api_key: String(parsed.api_key || defaults.api_key),
model: String(parsed.model || defaults.model),
max_tokens: Number.isFinite(parsed.max_tokens) ? Number(parsed.max_tokens) : defaults.max_tokens,
temperature: Number.isFinite(parsed.temperature) ? Number(parsed.temperature) : defaults.temperature,
};
}
export function getChatCompletionsUrl(baseUrl) {
const base = normalizeBaseUrl(baseUrl);
if (!base) return '';
if (base.endsWith('/v1')) return `${base}/chat/completions`;
return `${base}/v1/chat/completions`;
}
function normalizeMessages(messages) {
if (!Array.isArray(messages)) return [];
return messages
.map((message) => {
if (!message || typeof message !== 'object') return null;
const role = String(message.role || '').trim();
if (!role) return null;
const content = message.content;
if (typeof content === 'string') {
return { role, content };
}
if (Array.isArray(content)) {
return {
role,
content: content
.filter((part) => part && typeof part === 'object' && typeof part.type === 'string')
.map((part) => ({ ...part })),
};
}
return { role, content: String(content ?? '') };
})
.filter(Boolean);
}
export async function sendAiChatJson({
config,
messages,
responseFormat = { type: 'json_object' },
maxTokens,
temperature,
signal,
} = {}) {
const effective = config && typeof config === 'object' ? config : loadAiConfigLocal();
const apiKey = String(effective.api_key || '').trim();
const model = String(effective.model || '').trim();
const url = getChatCompletionsUrl(effective.base_url || '');
if (!apiKey) throw new Error('AI API key is not configured. Set it on ai.html first.');
if (!url) throw new Error('AI base URL is not configured. Set it on ai.html first.');
if (!model) throw new Error('AI model is not configured. Set it on ai.html first.');
const body = {
model,
messages: normalizeMessages(messages),
temperature: Number.isFinite(temperature) ? temperature : Number(effective.temperature ?? 0.2),
max_tokens: Number.isFinite(maxTokens) ? maxTokens : Number(effective.max_tokens ?? 4096),
};
if (responseFormat && typeof responseFormat === 'object') {
body.response_format = responseFormat;
}
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
});
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : {};
} catch {
parsed = { raw: text };
}
if (!response.ok) {
const detail = parsed?.error?.message || parsed?.message || response.statusText || 'Unknown error';
throw new Error(`AI request failed (${response.status}): ${detail}`);
}
const finishReason = String(parsed?.choices?.[0]?.finish_reason || '').trim().toLowerCase();
const truncated = finishReason === 'length';
const content = String(parsed?.choices?.[0]?.message?.content || '').trim();
if (!content) throw new Error('AI response did not include content.');
let json = null;
try {
json = JSON.parse(content);
} catch {
throw new Error('AI response was not valid JSON.');
}
return {
config: effective,
raw: parsed,
content,
json,
truncated,
warning: truncated ? '⚠️ Response was truncated due to max_tokens limit.' : '',
};
}