86 lines
3.0 KiB
JavaScript
86 lines
3.0 KiB
JavaScript
|
|
// 伺服器端呼叫已設定的 AI Provider(OpenCode Go / Grok)
|
|||
|
|
const AI_PROVIDERS = {
|
|||
|
|
'opencode-go': {
|
|||
|
|
label: 'OpenCode Go',
|
|||
|
|
endpoint: 'https://opencode.ai/zen/go/v1/chat/completions',
|
|||
|
|
keyEnv: 'OPENCODE_GO_API_KEY',
|
|||
|
|
modelEnv: 'OPENCODE_GO_MODEL',
|
|||
|
|
mode: 'chat',
|
|||
|
|
},
|
|||
|
|
grok: {
|
|||
|
|
label: 'Grok',
|
|||
|
|
endpoint: 'https://api.x.ai/v1/responses',
|
|||
|
|
keyEnv: 'GROK_API_KEY',
|
|||
|
|
modelEnv: 'GROK_MODEL',
|
|||
|
|
mode: 'responses',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function normalizeAIText(data, mode) {
|
|||
|
|
if (mode === 'responses') {
|
|||
|
|
if (data?.output_text) return data.output_text;
|
|||
|
|
const chunks = [];
|
|||
|
|
for (const item of data?.output || []) {
|
|||
|
|
for (const c of item?.content || []) {
|
|||
|
|
if (typeof c?.text === 'string') chunks.push(c.text);
|
|||
|
|
else if (typeof c?.content === 'string') chunks.push(c.content);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return chunks.join('\n').trim();
|
|||
|
|
}
|
|||
|
|
return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getActiveAIConfig() {
|
|||
|
|
const providerId = String(process.env.AI_ACTIVE_PROVIDER || 'grok').trim();
|
|||
|
|
const provider = AI_PROVIDERS[providerId];
|
|||
|
|
if (!provider) return null;
|
|||
|
|
const apiKey = String(process.env[provider.keyEnv] || '').trim();
|
|||
|
|
const model = String(process.env[provider.modelEnv] || '').trim();
|
|||
|
|
if (!apiKey) return null;
|
|||
|
|
return { providerId, provider, apiKey, model };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function extractJSONObject(text) {
|
|||
|
|
const raw = String(text || '').trim();
|
|||
|
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
|
|||
|
|
const candidate = fenced || raw;
|
|||
|
|
const start = candidate.indexOf('{');
|
|||
|
|
const end = candidate.lastIndexOf('}');
|
|||
|
|
if (start < 0 || end <= start) return null;
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(candidate.slice(start, end + 1));
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function callAI({ system, user, temperature = 0.15, timeoutMs = 90000 }) {
|
|||
|
|
const cfg = getActiveAIConfig();
|
|||
|
|
if (!cfg) return { ok: false, error: 'no_ai_key', text: null };
|
|||
|
|
let { model, provider, apiKey, providerId } = cfg;
|
|||
|
|
if (!model) model = 'grok-3-mini';
|
|||
|
|
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|||
|
|
try {
|
|||
|
|
const body = provider.mode === 'responses'
|
|||
|
|
? { model, store: false, temperature, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
|
|||
|
|
: { model, temperature, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] };
|
|||
|
|
const r = await fetch(provider.endpoint, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|||
|
|
body: JSON.stringify(body),
|
|||
|
|
signal: ctrl.signal,
|
|||
|
|
});
|
|||
|
|
const data = await r.json().catch(() => ({}));
|
|||
|
|
if (!r.ok) {
|
|||
|
|
return { ok: false, error: data?.error?.message || data?.message || `HTTP ${r.status}`, text: null, providerId };
|
|||
|
|
}
|
|||
|
|
return { ok: true, text: normalizeAIText(data, provider.mode), providerId, model };
|
|||
|
|
} catch (e) {
|
|||
|
|
return { ok: false, error: String(e?.message || e), text: null, providerId };
|
|||
|
|
} finally {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
}
|
|||
|
|
}
|