Build: Release v2.80.29
This commit is contained in:
+83
-9
@@ -4,10 +4,37 @@ import { getConfig } from '../config';
|
||||
import { buildApiUrl, logError, logInfo, resolveEngine, summarizeText, _getBrainDir } from '../utils';
|
||||
|
||||
/**
|
||||
* IAIService: AI 모델 호출에 대한 인터페이스
|
||||
* IAIService: AI 모델 호출에 대한 인터페이스.
|
||||
*
|
||||
* `call(prompt)` 는 plain user 메시지 1개만 보내는 legacy shortcut이고,
|
||||
* `chat({ system, user })` 는 role-aware 호출이다. Telegram 핸들러처럼
|
||||
* 모델을 grounding 해야 하는 경로에서는 system을 반드시 채워야 한다 —
|
||||
* gemma 같은 작은 모델은 system이 없으면 짧은/모호한 입력에 대해
|
||||
* "시는 못 써드려요" 같은 환각 거절을 하는 경향이 있다.
|
||||
*/
|
||||
export interface IAIService {
|
||||
call(prompt: string): Promise<string>;
|
||||
chat(req: AIChatRequest): Promise<AIChatResult>;
|
||||
}
|
||||
|
||||
export interface AIChatRequest {
|
||||
/** Optional system prompt. Strongly recommended for short / ambiguous user inputs. */
|
||||
system?: string;
|
||||
/** Required. The user message. */
|
||||
user: string;
|
||||
/** Optional override (default = config.defaultModel). */
|
||||
model?: string;
|
||||
/** Optional override (default = config.timeout). */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AIChatResult {
|
||||
content: string;
|
||||
/** Engine that actually returned the content. */
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
model: string;
|
||||
/** True iff content came back empty after all retries. Caller decides UX. */
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,35 +45,67 @@ export interface IBrainService {
|
||||
}
|
||||
|
||||
/**
|
||||
* AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체
|
||||
* AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Try the user-configured engine first; on transport / 5xx / empty response,
|
||||
* fall through to the other engine.
|
||||
* 2. Empty responses are treated as a soft failure: we log + retry the other
|
||||
* engine before giving up. Pure exceptions (network blip) trigger the same
|
||||
* fallback path.
|
||||
* 3. The legacy `call(prompt)` is preserved as a thin wrapper around `chat()`
|
||||
* for callers that don't have a system prompt — but new code should pass
|
||||
* a system prompt explicitly.
|
||||
*/
|
||||
export class AIService implements IAIService {
|
||||
public async call(prompt: string): Promise<string> {
|
||||
const result = await this.chat({ user: prompt });
|
||||
return result.content;
|
||||
}
|
||||
|
||||
public async chat(req: AIChatRequest): Promise<AIChatResult> {
|
||||
const config = getConfig();
|
||||
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
|
||||
const timeoutMs = req.timeoutMs ?? config.timeout;
|
||||
const primaryEngine = resolveEngine(config.ollamaUrl);
|
||||
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
|
||||
const engines = primaryEngine === 'lmstudio'
|
||||
? ['lmstudio', 'ollama'] as const
|
||||
: ['ollama', 'lmstudio'] as const;
|
||||
|
||||
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
|
||||
if (req.system && req.system.trim()) {
|
||||
messages.push({ role: 'system', content: req.system });
|
||||
}
|
||||
messages.push({ role: 'user', content: req.user });
|
||||
|
||||
let lastError: Error | null = null;
|
||||
let lastEmptyEngine: typeof engines[number] | null = null;
|
||||
|
||||
for (const engine of engines) {
|
||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||
const payload = {
|
||||
model: config.defaultModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
stream: false
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }),
|
||||
};
|
||||
|
||||
try {
|
||||
logInfo('[AIService] Request started.', { engine, apiUrl });
|
||||
logInfo('[AIService] Request started.', {
|
||||
engine, apiUrl, model,
|
||||
hasSystem: !!req.system, userChars: req.user.length,
|
||||
});
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(config.timeout)
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`AI call failed: ${res.status} ${summarizeText(rawText, 250)}`);
|
||||
logError(`[AIService] ${engine} HTTP ${res.status}`, { body: summarizeText(rawText, 250) });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -55,12 +114,27 @@ export class AIService implements IAIService {
|
||||
? (data.choices?.[0]?.message?.content || '')
|
||||
: (data.message?.content || data.response || '');
|
||||
|
||||
return content;
|
||||
if (!content || !content.trim()) {
|
||||
// Treat empty as soft failure so the other engine gets a chance.
|
||||
lastEmptyEngine = engine;
|
||||
lastError = new Error(`AI engine '${engine}' returned an empty response.`);
|
||||
logError(`[AIService] ${engine} empty response — falling through.`, { model });
|
||||
continue;
|
||||
}
|
||||
|
||||
return { content, engine, model, empty: false };
|
||||
} catch (error: any) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
logError(`[AIService] ${engine} failed:`, lastError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Both engines exhausted. Surface a result with empty=true so the
|
||||
// caller (e.g. Telegram handler) can produce a user-visible reply
|
||||
// instead of swallowing the failure.
|
||||
if (lastEmptyEngine) {
|
||||
return { content: '', engine: lastEmptyEngine, model, empty: true };
|
||||
}
|
||||
throw lastError || new Error('All AI engines failed.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user