feat: 지서버 절전 상태를 표시하고 모델 선택 시 자동으로 깨우기
지서버는 유휴 시 절전에 들어간다. 그동안 UI가 이를 "Offline"(빨강)으로만 보여줘서 고장난 것처럼 읽혔고, 실제로는 메시지를 보내면 자동으로 깨어나 정상 응답한다. 사용자가 붉은 표시를 보고 원인을 찾아 나선 뒤 발견했다. - /api/status가 sleeping을 함께 반환한다. 게이트를 찔러 확인하지 않고 wake_url 설정 유무로 판별하는데, 상태 표시는 10초마다 갱신되고 게이트를 찌르는 행위가 곧 매직패킷 발사라 확인하러 가면 지서버를 영영 못 자게 만든다. - 표시를 셋으로 분리: Online(초록) / 절전 중(노랑) / Offline(빨강). 노랑엔 "메시지를 보내면 자동으로 깨어납니다" 툴팁을 붙였다. 또한 설정에서 provider를 고르면 그 자리에서 깨우도록 했다. UI는 이미 선택 즉시 /api/models/test로 모델 목록을 가져오는데, 지서버가 자면 여기서 "모델 없음 — 서버가 실행 중인가요?"로 실패했다. 모델 선택은 쓰겠다는 의사가 분명하므로 이를 기상 트리거로 삼는다. 새 엔드포인트 없이 기존 동작에 얹었고, "자면 모델 목록이 빈다"는 문제도 같이 해결된다. 잠든 지서버로 실측 시 7초 만에 모델 5개를 받았고, 깨어있을 때는 0초로 통과해 불필요한 패킷을 보내지 않는다. wake_url이 없는 provider(클로서버 등)는 기존 동작 그대로다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+61
-2
@@ -3031,8 +3031,14 @@ app.get('/api/status', async (_req, res) => {
|
||||
const providerCfg = rawCfg.llm?.providers?.[provider] || {};
|
||||
const activeModel: string = providerCfg.model || rawCfg.models?.primary || 'unknown';
|
||||
const orchCfg = getOrchestrationConfig();
|
||||
// A wakeable provider (지서버) that is merely asleep is not the same as a broken one: the
|
||||
// next message fires a magic packet and gets answered. Deliberately inferred from config
|
||||
// rather than probed — /api/status polls every 10s, and poking the wol-gate to confirm is
|
||||
// exactly what sends the magic packet, so probing here would keep the box permanently awake.
|
||||
const sleeping = !connected && !!providerCfg.wake_url;
|
||||
res.json({
|
||||
status: 'ok', version: 'v2-tools', ollama: connected,
|
||||
sleeping,
|
||||
provider,
|
||||
currentModel: activeModel,
|
||||
workspace: (config as any).workspace?.path || '',
|
||||
@@ -4009,6 +4015,43 @@ function redactConfigForUI(obj: any, depth = 0): any {
|
||||
|
||||
// GET /api/settings/provider — return active provider config (keys redacted)
|
||||
// POST /api/settings/provider — update provider config
|
||||
/**
|
||||
* wake_url of the provider this request is about — the one being configured in the UI when an
|
||||
* override is supplied, otherwise whatever is active.
|
||||
*/
|
||||
function wakeUrlForLLM(llmOverride: any): string | undefined {
|
||||
const source = llmOverride || (getConfig().getConfig() as any)?.llm;
|
||||
const providerId = String(source?.provider || '');
|
||||
const raw = source?.providers?.[providerId]?.wake_url
|
||||
// The UI posts only the edited provider's fields, so fall back to what is stored.
|
||||
?? ((getConfig().getConfig() as any)?.llm?.providers?.[providerId]?.wake_url);
|
||||
return raw ? String(raw).replace(/\/+$/, '') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poke the wol-gate so it emits a magic packet, then wait for the box to start answering.
|
||||
*
|
||||
* Picking a sleeping provider in Settings used to just report "모델 없음 — 서버가 실행 중인가요?",
|
||||
* leaving the user to wake 지서버 by hand or to send a throwaway chat message purely to trigger
|
||||
* the wake. Selecting it is a clear enough intent to use it, so treat it as the trigger.
|
||||
*/
|
||||
async function wakeAndAwaitProvider(wakeUrl: string, provider: any, budgetMs = 45_000): Promise<boolean> {
|
||||
try {
|
||||
await fetch(`${wakeUrl}/api/tags`, { signal: AbortSignal.timeout(4_000) });
|
||||
} catch {
|
||||
// The gate answers a sleeping target with HTML (or nothing); the request itself is what
|
||||
// sends the packet, so a failure here does not mean the wake failed.
|
||||
}
|
||||
const deadline = Date.now() + budgetMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 3_000));
|
||||
try {
|
||||
if (await provider.testConnection()) return true;
|
||||
} catch { /* still booting */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /api/models/test — test connectivity for the active (or a given) provider
|
||||
app.post('/api/models/test', async (req, res) => {
|
||||
try {
|
||||
@@ -4025,9 +4068,25 @@ app.post('/api/models/test', async (req, res) => {
|
||||
}
|
||||
}
|
||||
const provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider();
|
||||
const ok = await provider.testConnection();
|
||||
let ok = await provider.testConnection();
|
||||
|
||||
// Unreachable but wakeable (지서버): selecting the provider is the wake trigger.
|
||||
let waking = false;
|
||||
if (!ok) {
|
||||
const wakeUrl = wakeUrlForLLM(llmOverride);
|
||||
if (wakeUrl) {
|
||||
waking = true;
|
||||
ok = await wakeAndAwaitProvider(wakeUrl, provider);
|
||||
}
|
||||
}
|
||||
|
||||
const models = ok ? await provider.listModels() : [];
|
||||
res.json({ success: ok, models, error: ok ? undefined : 'Could not connect' });
|
||||
res.json({
|
||||
success: ok,
|
||||
models,
|
||||
waking,
|
||||
error: ok ? undefined : (waking ? '깨우는 중입니다 — 잠시 후 다시 시도해 주세요' : 'Could not connect'),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.json({ success: false, models: [], error: err.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user