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:
kim
2026-08-09 10:40:07 +09:00
co-authored by Claude Opus 5
parent 3594d26047
commit c830c9fbf6
3 changed files with 92 additions and 9 deletions
+61 -2
View File
@@ -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 });
}