144 lines
4.3 KiB
JavaScript
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.' : '',
|
|
};
|
|
}
|