From 2228aedbf789050f9d42ded9b310f156e6b71933 Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 15 Jul 2026 18:16:20 +0900 Subject: [PATCH] =?UTF-8?q?v4.1.9:=20=EC=8A=A4=ED=8A=9C=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8E=B8=EC=A7=91=C2=B7?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20+=20?= =?UTF-8?q?=EC=9D=8C=EC=84=B1=EC=97=94=EC=A7=84=20=EC=A0=95=EB=A6=AC=20+?= =?UTF-8?q?=20GPU=20=EA=B2=8C=EC=9D=B4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스튜디오 앱: 사진 편집(스타일 변환/이모티콘 세트) + 결과 뷰어 내 크롭/회전/필터/보정/텍스트 오버레이 편집 툴바 + 저장 플로우(중간 편집은 갤러리에 안 남기고 최종본만 저장+업로드 목록에 재사용 등록) - image_style_transform: SDXL img2img + IP-Adapter-FaceID로 cartoon 스타일 추가 (watercolor/sketch는 identity drift로 재검토 후 제외) - 음성엔진 provider 값 xtts_gpu → omnivoice_gpu로 리네이밍 (실제 로딩 모델과 이름 일치, XTTS는 이미 OmniVoice로 교체된 지 오래) - 메인창 사이드바 GPU 게이지를 GPU0/GPU1로 분리 표시 - 단종된 gemini-3-flash-preview를 기본 모델값에서 전부 제거, kimi-k2.6:cloud로 교체 - vault 백업을 cron→systemd timer(Persistent=true)로 전환, /DATA 전체 동기화 스크립트 추가 Co-Authored-By: Claude Sonnet 5 --- .claude/settings.local.json | 4 +- .smallclaw/config.json | 15 +- .../papa/workspace/language/progress.json | 11 +- .../users/papa/workspace/prompts/USER.md | 16 +- package.json | 2 +- scripts/sync-data-backup.sh | 13 + src/config/config.ts | 8 +- src/gateway/routes-voice.ts | 2 +- src/gateway/routes/imagegen.ts | 312 ++++++- src/gateway/server-v2.ts | 37 +- src/tools/image.ts | 11 +- src/tools/imagegen.ts | 191 +++++ src/tools/registry.ts | 3 +- src/tools/tts.ts | 2 +- src/types.ts | 2 +- web-ui/app.js | 21 +- web-ui/index.html | 15 +- web-ui/studio-app.html | 788 +++++++++++++++++- web-ui/weather-app.html | 9 +- 19 files changed, 1366 insertions(+), 96 deletions(-) create mode 100755 scripts/sync-data-backup.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7ff33f4..f4ac4d3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -208,7 +208,9 @@ "Bash(~/.local/bin/arduino-cli core *)", "Bash(nvidia-smi --query-gpu=memory.used,memory.total,memory.free --format=csv)", "Bash(/home/kim/homeclaw/.smallclaw/voice-venv/bin/pip list *)", - "Bash(apt-cache policy *)" + "Bash(apt-cache policy *)", + "Bash(find / -maxdepth 4 -iname \"config.json\" -path \"*smallclaw*\")", + "Read(//root/**)" ] }, "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", diff --git a/.smallclaw/config.json b/.smallclaw/config.json index 9e8c2f1..eb915ab 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -21,7 +21,7 @@ "providers": { "ollama": { "endpoint": "http://localhost:11434", - "model": "gemini-3-flash-preview:cloud" + "model": "kimi-k2.6:cloud" }, "lm_studio": { "endpoint": "http://host.docker.internal:1234", @@ -45,11 +45,11 @@ } }, "models": { - "primary": "gemini-3-flash-preview:cloud", + "primary": "kimi-k2.6:cloud", "roles": { - "manager": "gemini-3-flash-preview:cloud", - "executor": "gemini-3-flash-preview:cloud", - "verifier": "gemini-3-flash-preview:cloud", + "manager": "kimi-k2.6:cloud", + "executor": "kimi-k2.6:cloud", + "verifier": "kimi-k2.6:cloud", "background_task": "" } }, @@ -239,7 +239,7 @@ "language": "ko" }, "tts": { - "provider": "xtts_gpu", + "provider": "omnivoice_gpu", "model": "ko_KR-kss-medium", "voice": "ko-KR-SunHiNeural" }, @@ -266,7 +266,8 @@ "google_cx": "", "ollama_model": "", "searxng_url": "http://localhost:8888", - "ollama_api_key": "vault:search.ollama_api_key" + "ollama_api_key": "vault:search.ollama_api_key", + "tavily_api_key": "vault:search.tavily_api_key" }, "agent_policy": { "force_web_for_fresh": true, diff --git a/.smallclaw/users/papa/workspace/language/progress.json b/.smallclaw/users/papa/workspace/language/progress.json index 1abf1d6..02b3d88 100644 --- a/.smallclaw/users/papa/workspace/language/progress.json +++ b/.smallclaw/users/papa/workspace/language/progress.json @@ -1,14 +1,7 @@ { "known": [ "Salom", - "Xayr", - "Ha", - "Yoʻq", - "Iltimos" + "Xayr" ], - "lessonsDone": { - "1": true, - "2": true, - "3": true - } + "lessonsDone": {} } \ No newline at end of file diff --git a/.smallclaw/users/papa/workspace/prompts/USER.md b/.smallclaw/users/papa/workspace/prompts/USER.md index d98f2e5..c1a2adb 100644 --- a/.smallclaw/users/papa/workspace/prompts/USER.md +++ b/.smallclaw/users/papa/workspace/prompts/USER.md @@ -22,4 +22,18 @@ - Local LLM Server name: 지서버 (Z-Server). [2026-07-03] [2026-07-03] - Web server name: 클로서버 (Claw-Server). [2026-07-03] [2026-07-03] - Web server (클로서버) 사양: HP ProDesk 600 G1 DM, 16GB RAM. [2026-07-06] [2026-07-06] -- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09] \ No newline at end of file +- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09] +- 클로서버(Claw-Server) AI 미디어 파이프라인 구성: + +[음성 STT/TTS] +- STT: faster-whisper medium, GPU 상주(voice_engine.py) +- TTS 메인: OmniVoice(k2-fsa/OmniVoice), GPU 상주, 음성 클로닝 지원. 설정값 voice.tts.provider === 'xtts_gpu'일 때 이 경로 탐(설정명은 xtts로 남아있으나 실제 모델은 OmniVoice) +- TTS 폴백: Piper(로컬 wav→ogg, ffmpeg), provider가 xtts_gpu가 아닐 때 구형 경로 +- 음성통화는 별도 실시간 경로(GPU 엔진 직결), 텍스트 버튼 TTS와 파이프라인 상이 + +[이미지/비디오 생성·편집] +- image_generate: SDXL(stabilityai/stable-diffusion-xl-base-1.0, 기본/fast) + FLUX.1-schnell(4-bit 양자화, quality="high" 옵션) +- video_generate: LTX-Video(Lightricks/LTX-Video) 텍스트→영상 +- image_style_transform: SDXL img2img + IP-Adapter-FaceID, style="cartoon"만 지원(watercolor·sketch는 identity drift로 되돌림) +- image_edit: crop/resize/rotate/flip/filter/vignette/watermark/speech_bubble/stylize/remove_bg 등. stylize=AnimeGANv2(torch.hub), remove_bg=rembg birefnet-portrait +- 모든 로컬 diffusion/LTX 작업은 전용 venv(imagegen-venv)에서 GPU1 고정 실행 [2026-07-15] \ No newline at end of file diff --git a/package.json b/package.json index 38ed139..7ad558b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.1.8", + "version": "4.1.9", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/scripts/sync-data-backup.sh b/scripts/sync-data-backup.sh new file mode 100755 index 0000000..851e1e5 --- /dev/null +++ b/scripts/sync-data-backup.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# /srv/homeclaw 전체를 /DATA(sda1, 별도 물리 디스크)로 동기화하는 백업 스크립트. +# cron: 0 4 * * * bash /srv/homeclaw/scripts/sync-data-backup.sh >> /home/kim/.smallclaw-backup/sync.log 2>&1 +set -e + +SRC="/srv/homeclaw/" +DST="/DATA/" +LOG_DIR="$HOME/.smallclaw-backup" +mkdir -p "$LOG_DIR" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync start" +rsync -a --delete --exclude=lost+found --exclude=.Trash-1000 "$SRC" "$DST" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync done ($(du -sh "$DST" | cut -f1))" diff --git a/src/config/config.ts b/src/config/config.ts index 7128fb0..d38ab40 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -116,11 +116,11 @@ export const DEFAULT_CONFIG: SmallClawConfig = { }, } as any, models: { - primary: 'gemini-3-flash-preview:cloud', + primary: 'kimi-k2.6:cloud', roles: { - manager: 'gemini-3-flash-preview:cloud', - executor: 'gemini-3-flash-preview:cloud', - verifier: 'gemini-3-flash-preview:cloud', + manager: 'kimi-k2.6:cloud', + executor: 'kimi-k2.6:cloud', + verifier: 'kimi-k2.6:cloud', background_task: '' } }, diff --git a/src/gateway/routes-voice.ts b/src/gateway/routes-voice.ts index dfc3631..f0e6ee4 100644 --- a/src/gateway/routes-voice.ts +++ b/src/gateway/routes-voice.ts @@ -37,7 +37,7 @@ app.post('/api/voice/tts', async (req: express.Request, res: express.Response) = try { const os = await import('os'); const { spawn: spawnProc } = await import('child_process'); - if (provider === 'xtts_gpu') { + if (provider === 'omnivoice_gpu') { const { synthesizeFullGPU } = await import('../tools/voice-engine-client.js'); const { pcm, sampleRate } = await synthesizeFullGPU(text); const pcmPath = path.join(os.tmpdir(), `tts_gpu_${Date.now()}.pcm`); diff --git a/src/gateway/routes/imagegen.ts b/src/gateway/routes/imagegen.ts index e69adba..14efbf0 100644 --- a/src/gateway/routes/imagegen.ts +++ b/src/gateway/routes/imagegen.ts @@ -1,7 +1,8 @@ import { Express, Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; -import { imageGenerateTool, videoGenerateTool } from '../../tools/imagegen.js'; +import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js'; +import { imageEditTool } from '../../tools/image.js'; const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const VIDEO_EXTS = new Set(['.mp4']); @@ -10,6 +11,52 @@ function galleryDir(workspace: string): string { return path.join(workspace, 'generated-media'); } +// Photo-edit style presets exposed in the studio UI, mapped onto image_edit's +// existing stylize/filter operations (AnimeGANv2 for neural styles — identity- +// preserving, already used by the chat tool — no separate img2img pipeline needed). +const EDIT_STYLE_MAP: Record = { + anime: { operation: 'stylize', style: 'painting' }, // 개인 인물 일러스트에 최적화된 프리셋 + anime_group: { operation: 'stylize', style: 'anime' }, // 단체/풍경용 + anime_celeba: { operation: 'stylize', style: 'celeba' }, // 만화체/웹툰 느낌 프리셋 + cartoon: { operation: 'stylize', style: 'cartoon' }, // image_edit의 카툰 필터 (SDXL_STYLES에서 오버라이드됨) + sketch: { operation: 'stylize', style: 'sketch' }, + sketch_color: { operation: 'stylize', style: 'sketch_color' }, + watercolor: { operation: 'stylize', style: 'watercolor' }, + bw: { operation: 'filter', preset: 'bw_film' }, +}; + +// cartoon supports multiple engines (engine param currently unused by the UI — it +// always sends 'sdxl' implicitly by omitting the field — but still callable via +// the chat tool or API directly): +// fast — image_edit's classic OpenCV filter (safe, mild, ~2-3s) +// sdxl — SDXL img2img + face-ID lock (default, ~15-40s) +// sdxl_no_facelock — SDXL img2img without face-ID lock (looser, more "painted") +// Other styles only have one engine and ignore this param — watercolor and sketch +// were both tried on SDXL and dropped (see imagegen.ts): the style jump from a +// color photo needed enough strength that, with no seed pinned, results sometimes +// drifted the face into a different-looking person. +const SDXL_STYLES = new Set(['cartoon']); +type Engine = 'fast' | 'sdxl' | 'sdxl_no_facelock'; +const ENGINES = new Set(['fast', 'sdxl', 'sdxl_no_facelock']); + +async function runStylize(style: string, srcPath: string, workspace: string, outputRelPath: string, engine: Engine = 'sdxl', strength?: number) { + if (SDXL_STYLES.has(style) && engine !== 'fast') { + return imageStyleTransformTool.execute({ + image: srcPath, style, + face_lock: engine !== 'sdxl_no_facelock', + strength, + output: outputRelPath, + _workspacePath: workspace, + }); + } + const cfg = EDIT_STYLE_MAP[style]; + return imageEditTool.execute({ + path: srcPath, operation: cfg.operation, style: cfg.style, preset: cfg.preset, + output: outputRelPath, + _workspacePath: workspace, + }); +} + export function registerImagegenRoutes(app: Express): void { app.post('/api/imagegen/generate-image', async (req: Request, res: Response) => { const user = (req as any).user; @@ -79,6 +126,228 @@ export function registerImagegenRoutes(app: Express): void { } }); + app.post('/api/imagegen/edit-image', async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const srcPath = String(req.body?.path || '').trim(); + const style = String(req.body?.style || '').trim(); + const engine = ENGINES.has(req.body?.engine) ? (req.body.engine as Engine) : 'sdxl'; + const strength = typeof req.body?.strength === 'number' ? req.body.strength : undefined; + const cfg = EDIT_STYLE_MAP[style]; + if (!srcPath) return res.status(400).json({ error: 'path is required' }); + if (!cfg) return res.status(400).json({ error: `Unknown style: ${style}` }); + + console.log(`[imagegen] edit-image start user=${user.username} style=${style} engine=${engine} strength=${strength} path=${srcPath}`); + try { + const outDir = galleryDir(user.workspace); + fs.mkdirSync(outDir, { recursive: true }); + const filename = `edit_${Date.now()}.png`; + + const result = await runStylize(style, srcPath, user.workspace, path.join('generated-media', filename), engine, strength); + + if (!result.success) { + console.error(`[imagegen] edit-image failed for user=${user.username}: ${result.error}`); + return res.status(500).json({ error: result.error }); + } + res.json({ + success: true, + url: `/api/files/${result.data.rel_path}`, + width: result.data.width, + height: result.data.height, + }); + } catch (err: any) { + console.error(`[imagegen] edit-image threw for user=${user.username}:`, err); + res.status(500).json({ error: err?.message || 'image_edit failed unexpectedly' }); + } + }); + + // General-purpose editor for the result-area toolbar (crop/rotate/flip/adjust/ + // filter/vignette/auto_enhance/watermark/speech_bubble/remove_bg) — a thin pass- + // through to image_edit rather than a route per operation, since image_edit + // already dispatches on `operation` internally. + // + // When the client sends a `session` id (studio UI does, for every edit after the + // first in a chain), output goes to a dot-prefixed subfolder under generated-media/ + // instead of the top level. fs.readdirSync in the gallery route is non-recursive + // and only matches known image/video extensions, so these intermediate steps never + // show up as gallery entries — same trick the sticker-set pipeline already uses for + // its own intermediate files. Nothing is "real" until /api/imagegen/save-result + // copies it out (see below), so idle tweaking doesn't clutter the gallery. + const QUICK_EDIT_PARAMS = ['x', 'y', 'width', 'height', 'keep_aspect', 'degrees', 'direction', 'value', 'radius', 'preset', 'strength', 'text', 'position', 'opacity', 'bg_color', 'text_color', 'border_color', 'font_size', 'quality']; + app.post('/api/imagegen/quick-edit', async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const srcPath = String(req.body?.path || '').trim(); + const operation = String(req.body?.operation || '').trim(); + const session = String(req.body?.session || '').trim().replace(/[^a-zA-Z0-9_-]/g, ''); + if (!srcPath) return res.status(400).json({ error: 'path is required' }); + if (!operation) return res.status(400).json({ error: 'operation is required' }); + + console.log(`[imagegen] quick-edit start user=${user.username} operation=${operation} session=${session||'-'} path=${srcPath}`); + try { + const outRelDir = session ? path.join('generated-media', `.edit-session-${session}`) : 'generated-media'; + fs.mkdirSync(path.join(user.workspace, outRelDir), { recursive: true }); + const filename = `edit_${Date.now()}.png`; + + const params: Record = {}; + for (const k of QUICK_EDIT_PARAMS) if (req.body[k] !== undefined) params[k] = req.body[k]; + + const result = await imageEditTool.execute({ + path: srcPath, operation, ...params, + output: path.join(outRelDir, filename), + _workspacePath: user.workspace, + }); + + if (!result.success) { + console.error(`[imagegen] quick-edit failed for user=${user.username}: ${result.error}`); + return res.status(500).json({ error: result.error }); + } + res.json({ + success: true, + url: `/api/files/${result.data.rel_path}`, + path: result.data.rel_path, + width: result.data.width, + height: result.data.height, + }); + } catch (err: any) { + console.error(`[imagegen] quick-edit threw for user=${user.username}:`, err); + res.status(500).json({ error: err?.message || 'image_edit failed unexpectedly' }); + } + }); + + // Commits the current result to the gallery (copying it out of a hidden + // .edit-session-*/ subfolder if it's in one) and optionally also to uploads/ so + // it becomes pickable as a source photo for future style transforms/stickers. + // No-op copy for the gallery half if the file is already directly under + // generated-media/ (a fresh generation/style-transform, never edited) — avoids + // creating a redundant duplicate entry. + app.post('/api/imagegen/save-result', async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const srcRel = String(req.body?.path || '').trim().replace(/\\/g, '/'); + const alsoUploads = !!req.body?.save_to_uploads; + if (!srcRel) return res.status(400).json({ error: 'path is required' }); + + const srcFull = path.join(user.workspace, srcRel); + if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' }); + + console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`); + try { + const ext = path.extname(srcRel) || '.png'; + const ts = Date.now(); + const isHidden = /^generated-media\/\.[^/]+\//.test(srcRel); + const isTopLevelGallery = srcRel.startsWith('generated-media/') && !isHidden; + + let galleryUrl: string; + if (isTopLevelGallery) { + galleryUrl = `/api/files/${srcRel}`; + } else { + const name = `saved_${ts}${ext}`; + const dest = path.join(galleryDir(user.workspace), name); + fs.copyFileSync(srcFull, dest); + galleryUrl = `/api/files/generated-media/${name}`; + } + + const out: Record = { success: true, gallery_url: galleryUrl }; + + if (alsoUploads) { + const name = `saved_${ts}${ext}`; + const uploadsDir = path.join(user.workspace, 'uploads'); + fs.mkdirSync(uploadsDir, { recursive: true }); + const dest = path.join(uploadsDir, name); + fs.copyFileSync(srcFull, dest); + out.uploads_url = `/api/files/uploads/${name}`; + out.uploads_path = `uploads/${name}`; + } + + // Clean up the temp session dir now that its result has been committed. + if (isHidden) { + const sessionDir = path.dirname(srcFull); + fs.rmSync(sessionDir, { recursive: true, force: true }); + } + + res.json(out); + } catch (err: any) { + console.error(`[imagegen] save-result threw for user=${user.username}:`, err); + res.status(500).json({ error: err?.message || 'save failed unexpectedly' }); + } + }); + + // Sticker set: stylize -> remove background -> resize -> stamp a caption per phrase. + // Each step reuses image_edit's existing operations; kept as a small chain of + // single-purpose calls rather than a new bespoke script. + app.post('/api/imagegen/make-sticker-set', async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const srcPath = String(req.body?.path || '').trim(); + const style = String(req.body?.style || '').trim(); + const engine = ENGINES.has(req.body?.engine) ? (req.body.engine as Engine) : 'sdxl'; + const strength = typeof req.body?.strength === 'number' ? req.body.strength : undefined; + const cfg = EDIT_STYLE_MAP[style]; + const phrases = (Array.isArray(req.body?.phrases) ? req.body.phrases : []) + .map((p: any) => String(p).trim()) + .filter(Boolean) + .slice(0, 12); + if (!srcPath) return res.status(400).json({ error: 'path is required' }); + if (!cfg) return res.status(400).json({ error: `Unknown style: ${style}` }); + if (!phrases.length) return res.status(400).json({ error: 'At least one phrase is required' }); + + console.log(`[imagegen] sticker-set start user=${user.username} style=${style} engine=${engine} strength=${strength} phrases=${phrases.length}`); + try { + const setId = `stickers_${Date.now()}`; + const setDir = path.join('generated-media', setId); + fs.mkdirSync(path.join(user.workspace, setDir), { recursive: true }); + + const styled = await runStylize(style, srcPath, user.workspace, path.join(setDir, 'styled.png'), engine, strength); + if (!styled.success) { + console.error(`[imagegen] sticker-set stylize failed for user=${user.username}: ${styled.error}`); + return res.status(500).json({ error: styled.error || 'Stylize step failed' }); + } + + const cutout = await imageEditTool.execute({ + path: styled.data.rel_path, operation: 'remove_bg', + output: path.join(setDir, 'cutout.png'), + _workspacePath: user.workspace, + }); + if (!cutout.success) { + console.error(`[imagegen] sticker-set remove_bg failed for user=${user.username}: ${cutout.error}`); + return res.status(500).json({ error: cutout.error || 'Background removal step failed' }); + } + + const base = await imageEditTool.execute({ + path: cutout.data.rel_path, operation: 'thumbnail', width: 480, height: 480, + output: path.join(setDir, 'base.png'), + _workspacePath: user.workspace, + }); + if (!base.success) { + console.error(`[imagegen] sticker-set resize failed for user=${user.username}: ${base.error}`); + return res.status(500).json({ error: base.error || 'Resize step failed' }); + } + + const stickers: { url: string; phrase: string }[] = []; + for (let i = 0; i < phrases.length; i++) { + const r = await imageEditTool.execute({ + path: base.data.rel_path, operation: 'watermark', + text: phrases[i], position: 'bottom-right', opacity: 0.9, + output: path.join(setDir, `sticker_${i + 1}.png`), + _workspacePath: user.workspace, + }); + if (r.success) stickers.push({ url: `/api/files/${r.data.rel_path}`, phrase: phrases[i] }); + else console.error(`[imagegen] sticker-set caption "${phrases[i]}" failed for user=${user.username}: ${r.error}`); + } + if (!stickers.length) return res.status(500).json({ error: 'No stickers were generated' }); + + res.json({ success: true, stickers }); + } catch (err: any) { + console.error(`[imagegen] sticker-set threw for user=${user.username}:`, err); + res.status(500).json({ error: err?.message || 'sticker set generation failed unexpectedly' }); + } + }); + app.get('/api/imagegen/gallery', (req: Request, res: Response) => { const user = (req as any).user; if (!user) return res.status(401).json({ error: 'Unauthorized' }); @@ -116,4 +385,45 @@ export function registerImagegenRoutes(app: Express): void { fs.unlinkSync(target); res.json({ success: true }); }); + + // Uploaded source photos (shared uploads/ dir — also used by other apps like + // pptx/dental — filtered to image files so the studio picker only shows photos). + app.get('/api/imagegen/uploads', (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const dir = path.join(user.workspace, 'uploads'); + if (!fs.existsSync(dir)) return res.json({ items: [] }); + + const items = fs.readdirSync(dir) + .map((name) => { + const ext = path.extname(name).toLowerCase(); + if (!IMAGE_EXTS.has(ext)) return null; + const full = path.join(dir, name); + const stat = fs.statSync(full); + if (!stat.isFile()) return null; + return { + name, + path: `uploads/${name}`, + url: `/api/files/uploads/${encodeURIComponent(name)}`, + size: stat.size, + mtime: stat.mtimeMs, + }; + }) + .filter((x): x is NonNullable => x !== null) + .sort((a, b) => b.mtime - a.mtime); + + res.json({ items }); + }); + + app.delete('/api/imagegen/uploads/:name', (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const name = path.basename(String(req.params.name || '')); + const target = path.join(user.workspace, 'uploads', name); + if (!fs.existsSync(target)) return res.status(404).json({ error: 'Not found' }); + fs.unlinkSync(target); + res.json({ success: true }); + }); } diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 73310cd..878661b 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -1388,10 +1388,12 @@ stylize: style 파라미터로 스타일 지정. - 신경망(고품질): "anime"(기본, 애니/그림체), "painting"(유화), "celeba"(만화체), "anime_v1" - 전통필터: "sketch"(연필스케치), "sketch_color"(컬러스케치), "cartoon"(만화), "watercolor"(수채화) 사용 예: image_edit({path:"uploads/photo.jpg", operation:"stylize", style:"anime"}) -remove_bg: AI 배경 제거, PNG 출력 +remove_bg: AI 배경 제거(birefnet-portrait, 머리카락 디테일까지 정교), PNG 출력, CPU라 20~30초 정도 걸림 — 오래 걸린다고 미리 안내할 것 speech_bubble: text, position("top-left"|"top-right"|"bottom-left"|"bottom-right"), bg_color, text_color, font_size(자동) 워크플로우: (1) [IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ...] 힌트에서 입력 경로 읽기 → (2) image_edit 호출 → (3) 결과 자동 표시 → 재편집 가능. -출력: uploads/ 폴더에 자동 저장.`, +출력: uploads/ 폴더에 자동 저장. + +IMAGE STYLE TRANSFORM TOOL (SDXL+IP-Adapter-FaceID, 고품질 화풍): image_style_transform(image, style) → style="cartoon"(캐리커쳐/만화 일러스트)만 지원. image_edit의 stylize cartoon은 기계적 필터라 은은한 보정에 가깝고, 이 툴은 SDXL img2img로 실제 화풍을 새로 그려서 훨씬 더 그림다운 결과를 냄. 얼굴이 감지되면 IP-Adapter-FaceID로 identity를 고정해서 원본과 다른 사람이 되는 문제를 막음. 대신 15~40초로 느림. 사용자가 "캐리커쳐로", "화질 좋게 카툰으로" 등 고품질 화풍 변환을 명시적으로 요청할 때만 사용 — 기본 카툰 요청에는 image_edit(stylize)를 먼저 쓰고, 결과가 밋밋하다고 하면 이 툴을 제안. watercolor와 sketch는 SDXL로 시도했다가 모두 되돌렸음(시드 고정 없이 강한 strength가 필요해서 원본과 다른 사람 얼굴로 드리프트하는 경우가 있었음) — 항상 image_edit 사용. anime/흑백 요청에도 이 툴 대신 image_edit 사용(더 빠름).`, weather: `WEATHER TOOLS (8개): ① weather_search(location, type?, units?) → OpenWeather 현재날씨/5일예보. type:"current"|"forecast". @@ -12895,7 +12897,7 @@ app.get('/api/system-stats', async (req, res) => { // GPU stats — use the cached detector (probed once at startup, never calls // nvidia-smi again). On non-NVIDIA systems this is instant and silent. const gpuInfo = detectGpu(); - let gpuStats = { available: false, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: '' }; + let gpuStats: any = { available: false, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: '', gpus: [] as any[] }; if (gpuInfo.nvidiaAvailable) { // Re-query utilization metrics only when NVIDIA is confirmed present. // This is the *only* place nvidia-smi runs at runtime; startup detection @@ -12911,6 +12913,8 @@ app.get('/api/system-stats', async (req, res) => { // (e.g. memory.total gets "12288\nNVIDIA GeForce RTX 3060" glued on, NaN's // the VRAM percent). Aggregate across all detected GPUs: sum VRAM (so the // meter reflects total usage), max utilization (so activity on any card shows). + // Also keep the per-GPU breakdown (gpus[]) so the UI can show each card + // separately instead of only a blended combined figure. const gpuLines = smiOut.trim().split('\n') .map((line: string) => line.split(',').map((s: string) => s.trim())) .filter((parts: string[]) => parts.length >= 4); @@ -12919,12 +12923,24 @@ app.get('/api/system-stats', async (req, res) => { let vramTotalMbSum = 0; let utilMax = 0; const names: string[] = []; - for (const parts of gpuLines) { - vramUsedMbSum += Number(parts[2]) || 0; - vramTotalMbSum += Number(parts[3]) || 0; - utilMax = Math.max(utilMax, Number(parts[1]) || 0); + const perGpu: any[] = []; + gpuLines.forEach((parts: string[], idx: number) => { + const vramUsedMb = Number(parts[2]) || 0; + const vramTotalMb = Number(parts[3]) || 0; + const util = Number(parts[1]) || 0; + vramUsedMbSum += vramUsedMb; + vramTotalMbSum += vramTotalMb; + utilMax = Math.max(utilMax, util); names.push(parts[0]); - } + perGpu.push({ + index: idx, + name: parts[0], + gpu_util_percent: util, + vram_used_percent: vramTotalMb > 0 ? (vramUsedMb / vramTotalMb) * 100 : 0, + vram_used_gb: vramUsedMb / 1024, + vram_total_gb: vramTotalMb / 1024, + }); + }); gpuStats = { available: true, name: gpuLines.length > 1 ? `${names[0]} ×${gpuLines.length}` : names[0], @@ -12932,6 +12948,7 @@ app.get('/api/system-stats', async (req, res) => { vram_used_percent: vramTotalMbSum > 0 ? (vramUsedMbSum / vramTotalMbSum) * 100 : 0, vram_used_gb: vramUsedMbSum / 1024, vram_total_gb: vramTotalMbSum / 1024, + gpus: perGpu, }; } } catch { /* nvidia-smi already confirmed working at startup; ignore transient errors */ } @@ -14429,10 +14446,10 @@ server.listen(PORT, HOST, async () => { // Auto-connect enabled MCP servers getMCPManager().startEnabledServers().catch(err => console.warn('[MCP] Startup error:', err?.message)); - // GPU voice engine (faster-whisper + XTTS) — only if configured as the active provider, + // GPU voice engine (faster-whisper + OmniVoice) — only if configured as the active provider, // starts lazily in the background so a slow/failed GPU load never blocks gateway boot. const voiceCfg = (liveConfig as any).voice; - if (voiceCfg?.enabled && (voiceCfg?.stt?.provider === 'whisper_gpu' || voiceCfg?.tts?.provider === 'xtts_gpu')) { + if (voiceCfg?.enabled && (voiceCfg?.stt?.provider === 'whisper_gpu' || voiceCfg?.tts?.provider === 'omnivoice_gpu')) { import('../tools/voice-engine-client.js') .then((m) => m.ensureEngineRunning()) .then(() => console.log('[VoiceEngine] GPU voice engine ready.')) diff --git a/src/tools/image.ts b/src/tools/image.ts index cc3fbc7..cf769a3 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -490,11 +490,15 @@ try: elif op == "remove_bg": try: - from rembg import remove as _rembg_remove + from rembg import remove as _rembg_remove, new_session as _rembg_new_session except ImportError: print(json.dumps({"error": "rembg not installed. Run: pip install rembg[cpu]"})); sys.exit(0) _inp = img.convert("RGBA") - img = _rembg_remove(_inp) + # birefnet-portrait: much finer hair-strand matting than the old u2net default, + # at the cost of speed (~4s -> ~25s on CPU, no GPU onnxruntime provider installed). + # Worth it for the sticker-set pipeline where cutout edge quality matters. + _session = _rembg_new_session("birefnet-portrait") + img = _rembg_remove(_inp, session=_session) # force PNG output (preserves transparency) if not dst.lower().endswith(".png"): dst = os.path.splitext(dst)[0] + ".png" @@ -624,7 +628,7 @@ export const imageEditTool = { ' watermark — overlay text (text, position, opacity)', ' speech_bubble — add a speech bubble with tail (text, position, bg_color, text_color, border_color, font_size)', ' stylize — convert to artistic style. Neural: anime(default)|painting|celeba|anime_v1 (AnimeGANv2). Classic: sketch|sketch_color|cartoon|watercolor', - ' remove_bg — remove background using AI (rembg); output is PNG with transparency', + ' remove_bg — remove background using AI (rembg, birefnet-portrait model — fine hair-strand matting); output is PNG with transparency. Slower than most operations (~20-30s on CPU).', ' convert — change file format (output path determines format)', 'Returns the output file path and new dimensions.', ].join('\n'), @@ -726,6 +730,7 @@ export const imageEditTool = { opacity: args?.opacity ?? 0.5, quality: args?.quality ?? 92, style: args?.style ?? 'painting', + preset: args?.preset ?? 'warm', }; // Neural stylize (anime/painting/celeba) downloads PyTorch models on first run (~5 min). diff --git a/src/tools/imagegen.ts b/src/tools/imagegen.ts index 6dd4a5f..8d0b05f 100644 --- a/src/tools/imagegen.ts +++ b/src/tools/imagegen.ts @@ -184,6 +184,197 @@ except Exception as e: print("###RESULT###" + json.dumps({"error": str(e), "trace": traceback.format_exc()[-800:]})) `; +// --------------------------------------------------------------------------- +// image_style_transform — local SDXL img2img (cartoon/watercolor only) +// --------------------------------------------------------------------------- +// image_edit's stylize op (AnimeGANv2 + OpenCV) already covers anime/sketch/bw +// well — those are identity-preserving and near-instant. But its "cartoon" and +// "watercolor" presets are mechanical edge/color filters that read as a mild +// photo filter rather than real artistic reinterpretation. SDXL img2img trades +// speed and some facial-identity fidelity for a genuinely more painterly result +// on those two styles specifically. +const IMG2IMG_SCRIPT = (p: Record) => ` +import os, json, sys +try: + import torch + from diffusers import StableDiffusionXLImg2ImgPipeline + from PIL import Image, ImageOps + + pipe = StableDiffusionXLImg2ImgPipeline.from_pretrained( + "stabilityai/stable-diffusion-xl-base-1.0", + torch_dtype=torch.float16, variant="fp16", use_safetensors=True, + ) + pipe = pipe.to("cuda") + pipe.enable_vae_slicing() + + init_image = ImageOps.exif_transpose(Image.open(${JSON.stringify(p.src)})).convert("RGB") + max_side = int(${p.max_side}) + w, h = init_image.size + scale = min(1.0, max_side / max(w, h)) + w, h = max(8, int(w * scale) // 8 * 8), max(8, int(h * scale) // 8 * 8) + init_image = init_image.resize((w, h)) + + # Face-ID lock (IP-Adapter-FaceID/SDXL): without this, img2img alone lets the + # style prompt pull the face toward SDXL's own learned priors — this is what + # caused the watercolor identity-drift problem. Extracting a face embedding and + # conditioning generation on it keeps the result recognizably the same person. + # Falls back to plain img2img if no face is detected (e.g. non-portrait photo). + face_embeds = None + if ${p.face_lock ? 'True' : 'False'}: + try: + import cv2 + from insightface.app import FaceAnalysis + _face_app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"]) + _face_app.prepare(ctx_id=0, det_size=(640, 640)) + _cv_img = cv2.imread(${JSON.stringify(p.src)}) + _faces = _face_app.get(_cv_img) + if _faces: + _emb = torch.from_numpy(_faces[0].normed_embedding).unsqueeze(0).unsqueeze(0) + face_embeds = torch.cat([torch.zeros_like(_emb), _emb], dim=0).to(dtype=torch.float16, device="cuda") + except Exception: + face_embeds = None + + kwargs = dict( + prompt=${JSON.stringify(p.prompt)}, + negative_prompt=${JSON.stringify(p.negative_prompt)}, + image=init_image, + strength=float(${p.strength}), + num_inference_steps=int(${p.steps}), + guidance_scale=float(${p.guidance_scale}), + ) + ${p.seed != null ? `kwargs["generator"] = torch.Generator("cuda").manual_seed(int(${p.seed}))` : ''} + + if face_embeds is not None: + pipe.load_ip_adapter("h94/IP-Adapter-FaceID", subfolder=None, weight_name="ip-adapter-faceid_sdxl.bin", image_encoder_folder=None) + pipe.set_ip_adapter_scale(0.8) + kwargs["ip_adapter_image_embeds"] = [face_embeds] + + image = pipe(**kwargs).images[0] + dst = ${JSON.stringify(p.dst)} + os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True) + image.save(dst) + print("###RESULT###" + json.dumps({ + "output": dst, "width": image.width, "height": image.height, + "face_locked": face_embeds is not None, + "vram_peak_mb": torch.cuda.max_memory_allocated() / 1024**2, + })) +except Exception as e: + import traceback + print("###RESULT###" + json.dumps({"error": str(e), "trace": traceback.format_exc()[-800:]})) +`; + +const IMG2IMG_STYLE_PRESETS: Record = { + cartoon: { + // strength was 0.6 — looked like a genuine caricature but the pose/expression + // ("인상") drifted too far from the source photo even with face-ID identity + // locked, since face-ID only conditions identity, not expression/composition. + // 0.45 keeps the photo's actual expression and framing intact while still + // applying visible cel-shaded/cartoon coloring and clean outlines. + prompt: 'caricature portrait illustration, exaggerated facial features, bold clean outlines, vibrant flat colors, humorous comic art style, digital illustration', + negative: 'photorealistic, blurry, deformed hands, extra limbs, low quality, watermark, text', + strength: 0.45, + guidance_scale: 7.0, + }, + // "watercolor" was tried here too but dropped: the "watercolor painting portrait" + // prompt pulled SDXL toward its own learned face priors much harder than cartoon + // does — even down to strength 0.25-0.3 it reliably drifted the subject toward a + // different-looking (often different-gender-presenting) face, with the watercolor + // effect barely visible at the strengths low enough to keep identity intact. + // image_edit's classic OpenCV watercolor filter (mild but identity-safe) is used + // instead — see EDIT_STYLE_MAP in gateway/routes/imagegen.ts. + // + // "sketch" was tried too and also dropped: going from a color photo to monochrome + // graphite linework needed strength/guidance high enough (0.8/9.0) that, with no + // seed pinned, run-to-run variance sometimes drifted the face into a different- + // looking person (same failure shape as watercolor). Backing off to 0.6/8.0 for + // more consistent identity made the result look worse than image_edit's classic + // sketch filter — flat/muddy rather than either a clean sketch or a good likeness. + // Reverted to image_edit's OpenCV sketch filter (see EDIT_STYLE_MAP). +}; + +export const imageStyleTransformTool = { + name: 'image_style_transform', + description: [ + 'Transform an existing photo (e.g. a portrait) into a new artistic style using local SDXL img2img with IP-Adapter-FaceID identity locking (runs on-machine GPU, no external API).', + 'Supports style="cartoon" (caricature/comic illustration) — more artistically interpretive than image_edit\'s classic OpenCV cartoon filter, at the cost of speed (~15-40s vs ~2-3s). When a face is detected in the source photo, a face embedding (insightface/buffalo_l) conditions the generation via IP-Adapter-FaceID so the result stays recognizably the same person even under a strong style prompt — falls back to plain img2img if no face is found.', + 'For anime/sketch/watercolor/black-and-white, use image_edit\'s stylize/filter operations instead — those are faster (both "watercolor" and "sketch" SDXL presets were tried and dropped: the style jump needed enough strength that, with no seed pinned, results sometimes drifted the face into a different-looking person).', + 'Returns the transformed image inline in the chat.', + ].join('\n'), + schema: { + image: 'Path to the source image to transform (required)', + style: '"cartoon" (caricature/comic illustration) — currently the only supported style', + face_lock: 'Whether to lock facial identity via IP-Adapter-FaceID when a face is detected (optional, default true). Turn off to compare plain img2img — face-ID conditioning sometimes reads as slightly uncanny/over-smoothed; plain img2img gives looser but more natural-looking results.', + strength: 'How strongly to restyle, 0.05-1.0 (optional; default 0.45). Lower preserves the original photo\'s pose/expression more, higher restyles more aggressively but can drift away from them.', + steps: 'Denoising steps (default 40, range 15-50)', + guidance_scale: 'How closely to follow the style prompt (default 7.0)', + seed: 'Random seed for reproducibility (optional)', + output: 'Output file path (optional; defaults to a timestamped file in the workspace)', + }, + jsonSchema: { + type: 'object', + properties: { + image: { type: 'string' }, + style: { type: 'string', enum: ['cartoon'] }, + face_lock: { type: 'boolean' }, + strength: { type: 'number' }, + steps: { type: 'number' }, + guidance_scale: { type: 'number' }, + seed: { type: 'number' }, + output: { type: 'string' }, + }, + required: ['image', 'style'], + additionalProperties: false, + }, + execute: async (args: any): Promise => { + const imageArg = String(args?.image || '').trim(); + if (!imageArg) return { success: false, error: 'image is required' }; + const style = String(args?.style || '').trim(); + const preset = IMG2IMG_STYLE_PRESETS[style]; + if (!preset) return { success: false, error: `Unknown style: ${style} (expected "cartoon")` }; + + const workspacePath = getWorkspacePath(args); + const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg); + if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` }; + + let outPath = String(args?.output || '').trim(); + if (!outPath) { + outPath = path.join(workspacePath, `${style}_${Date.now()}.png`); + } else if (!path.isAbsolute(outPath)) { + outPath = path.resolve(workspacePath, outPath); + } + + const params = { + src: srcPath, + prompt: preset.prompt, + negative_prompt: preset.negative, + strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)), + steps: Math.min(50, Math.max(15, args?.steps ?? 40)), + guidance_scale: args?.guidance_scale ?? preset.guidance_scale, + seed: args?.seed, + face_lock: args?.face_lock ?? true, + max_side: 1024, + dst: outPath, + }; + // 600s (not 180s like the other scripts): the face-ID adapter weights (~1.7GB) + // download from HF Hub on first run and can take several minutes. + const result = await runVenvPython(IMG2IMG_SCRIPT(params), 600_000); + if (result.error || !result.output) { + return { success: false, error: result.error || 'Generator returned no output file', stderr: result.trace || result.raw }; + } + + return { + success: true, + stdout: [ + `Style: ${style}`, + `Transformed: ${result.width} × ${result.height} px`, + '', + buildImageMarkdown(result.output, workspacePath), + ].join('\n'), + data: { ...result, rel_path: path.relative(workspacePath, result.output).replace(/\\/g, '/') }, + }; + }, +}; + export const imageGenerateTool = { name: 'image_generate', description: [ diff --git a/src/tools/registry.ts b/src/tools/registry.ts index ab8c2ca..dc2ced1 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -17,7 +17,7 @@ import { openalexSearchTool, semanticSearchTool } from './scholar.js'; import { pdfReadTool } from './pdf.js'; import { pdfExtractImagesTool, pdfExtractTablesTool } from './pdf-extract.js'; import { imageReadTool, imagePreviewTool, imageInfoTool, imageEditTool } from './image.js'; -import { imageGenerateTool, videoGenerateTool } from './imagegen.js'; +import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from './imagegen.js'; import { audioTranscribeTool } from './audio-transcribe.js'; import { pythonEvalTool } from './python.js'; import { sqliteTool } from './sqlite.js'; @@ -206,6 +206,7 @@ class ToolRegistry { this.registerSafe(imageInfoTool); this.registerSafe(imageEditTool); this.registerSafe(imageGenerateTool); + this.registerSafe(imageStyleTransformTool); this.registerSafe(videoGenerateTool); this.registerSafe(audioTranscribeTool); this.registerSafe(pythonEvalTool); diff --git a/src/tools/tts.ts b/src/tools/tts.ts index 2e056af..7fd9922 100644 --- a/src/tools/tts.ts +++ b/src/tools/tts.ts @@ -35,7 +35,7 @@ export async function synthesizeSpeech(text: string): Promise { const provider = cfg?.voice?.tts?.provider; const truncatedText = text.slice(0, 4000); - if (provider === 'xtts_gpu') { + if (provider === 'omnivoice_gpu') { const tempDir = cfg?.voice?.tempDir || os.tmpdir(); const ffmpegPath = cfg?.voice?.ffmpegPath || 'ffmpeg'; const { synthesizeFullGPU } = await import('./voice-engine-client.js'); diff --git a/src/types.ts b/src/types.ts index 81583bc..74d5a35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -364,7 +364,7 @@ export interface SmallClawConfig { language?: string; }; tts: { - provider: 'piper' | 'edge_tts' | 'xtts_gpu'; + provider: 'piper' | 'edge_tts' | 'omnivoice_gpu'; model: string; piperPath?: string; modelPath?: string; diff --git a/web-ui/app.js b/web-ui/app.js index 1cdf59d..a4ed745 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -4736,10 +4736,21 @@ async function loadSystemStats() { // 사이드바 리소스 바 setText('sb-sys-cpu', fmtPercent(cpu)); setText('sb-sys-ram', fmtPercent(ram)); - setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A'); setMeter('sb-sys-cpu-meter', cpu); setMeter('sb-sys-ram-meter', ram); - setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0); + + // GPU별 VRAM — 카드가 2개 이상이면 GPU0/GPU1 분리 표시, 1개면 GPU1 행 숨김 + const gpuList = Array.isArray(stats?.gpu?.gpus) ? stats.gpu.gpus : []; + const gpu0 = gpuList[0]; + const gpu1 = gpuList[1]; + setText('sb-sys-vram0', gpu0 ? fmtPercent(gpu0.vram_used_percent) : (gpuAvailable ? fmtPercent(vram) : 'N/A')); + setMeter('sb-sys-vram0-meter', gpu0 ? gpu0.vram_used_percent : (gpuAvailable ? vram : 0)); + const gpu1Row = document.getElementById('sb-gpu1-row'); + if (gpu1Row) gpu1Row.style.display = gpu1 ? 'flex' : 'none'; + if (gpu1) { + setText('sb-sys-vram1', fmtPercent(gpu1.vram_used_percent)); + setMeter('sb-sys-vram1-meter', gpu1.vram_used_percent); + } const rxBps = stats?.network?.rx_bps || 0; const txBps = stats?.network?.tx_bps || 0; setText('sb-sys-rx', fmtNetSpeed(rxBps)); @@ -4800,10 +4811,12 @@ async function loadSystemStats() { setMeter('sys-vram-meter', 0); setText('sb-sys-cpu', '--%'); setText('sb-sys-ram', '--%'); - setText('sb-sys-vram', 'N/A'); + setText('sb-sys-vram0', 'N/A'); + setText('sb-sys-vram1', 'N/A'); setMeter('sb-sys-cpu-meter', 0); setMeter('sb-sys-ram-meter', 0); - setMeter('sb-sys-vram-meter', 0); + setMeter('sb-sys-vram0-meter', 0); + setMeter('sb-sys-vram1-meter', 0); setText('sb-sys-rx', '0 B/s'); setText('sb-sys-tx', '0 B/s'); setMeter('sb-sys-rx-meter', 0); diff --git a/web-ui/index.html b/web-ui/index.html index 6c531ea..1f97380 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -153,10 +153,15 @@
--% -
- GPU -
- --% +
+ GPU0 +
+ --% +
+
@@ -829,7 +834,7 @@
- + + @@ -369,7 +370,7 @@ async function ensureChatContextForLoc(){ addMsg('system',l?`📍 '${escHtml(l.name)}' 위치 컨텍스트입니다. 이 대화는 이 위치에서만 이어집니다.`:'🌍 전체 컨텍스트입니다. (선택된 위치 없음)'); } -const SKILL_CONTENT="---\nname: Meteorologist\ndescription: 날씨 분석·기후 해설·기상 현상 설명·예보 해석을 제공하는 기상 전문가 어시스턴트. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 정확하고 쉬운 설명 제공.\nemoji: \"🌤️\"\nversion: 1.3.0\ntriggers: [날씨, 기온, 기상, 비, 눈, 바람, 태풍, 예보, 미세먼지, 황사, 폭염, 한파, 습도, 강수, 대기질, 기후, 구름, 우박, 번개, 천둥, 안개, 서리, 열대야, 소나기]\n---\n\n당신은 기상학 전문가 어시스턴트입니다. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 날씨 예보 해석, 기상 현상 설명, 기후 분석, 자연재해 경보 해설을 제공하세요.\n\n**언어**: 사용자가 한국어로 말하면 반드시 한국어로만 답변. 기상 용어는 한국어 먼저, 영어 괄호 병기. (예: 적란운(cumulonimbus))\n\n---\n\n### 핵심 원칙\n\n**⛔ 날씨 수치 무단 생성 금지**: 실시간·과거 기상 수치는 반드시 도구를 호출한 후 반환된 데이터만 보고하세요. 도구 없이 날씨를 서술하면 오보입니다.\n\n**⛔ 이전 대화 수치 재사용 금지**: 같은 지역·날짜라도 새 질문마다 도구를 새로 호출하세요.\n\n- 불확실한 예보는 \"기상청 최신 예보를 확인하세요\"로 명시\n- 수치 인용 시 출처(기상청·ECMWF·NOAA 등) 표기\n- 전문 용어 사용 시 쉬운 말로 한 줄 병기\n- 위험 기상 시 행동 요령 우선 안내\n\n---\n\n### 🗺️ Windy 지도 임베드\n\n사용자 메시지에 아래 **트리거 단어**가 포함될 때만 Windy URL을 출력하세요. 그 외에는 절대 출력하지 마세요.\n\n**트리거 단어**: 기상도, 위성사진, 위성영상, 레이더, 강수레이더, 바람지도, 풍향지도, 기압도, 일기도, 구름사진, 구름영상, 적설지도, 파고지도, 태풍, 태풍위치, 태풍경로, 허리케인, 사이클론\n\n트리거 단어가 있을 때: `https://embed.windy.com/embed2.html?lat=LAT&lon=LON&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` 형식으로 LAT·LON·OVERLAY·ZOOM을 맥락에 맞게 채워 **독립된 한 줄**로 출력하세요.\n\n**LAT·LON은 항상 실제로 묻는 지역 기준으로 바꾸세요** — 서울(37.5, 126.9)을 기본값처럼 고정해서 복사하지 마세요. 지역이 특정되지 않았다면 사용자의 기본 지역(서울)을 쓰세요.\n\n**주요 지역 좌표**: 서울(37.5,126.9) 부산(35.18,129.08) 인천(37.46,126.7) 대구(35.87,128.6) 광주(35.16,126.85) 대전(36.35,127.38) 제주(33.5,126.53) 강릉(37.75,128.9) 평양(39.02,125.75) 도쿄(35.68,139.69) 베이징(39.9,116.4) 상하이(31.23,121.47) — 목록에 없는 지역은 알고 있는 좌표를 사용하세요.\n\n**중요**: URL을 출력한 직후 반드시 `weather_map_screenshot` 도구를 그 URL 그대로 호출해서 실제로 렌더링된 지도를 확인하세요. 캡처한 이미지를 보기 전에는 강수·구름·레이더 패턴을 절대 추측하지 마세요 — 이미지에 실제로 보이는 것만 근거로 설명하세요.\n\n**태풍 위치/경로 요청 시**: lat·lon을 서울이나 기본 지역이 아니라 **태풍의 실제 현재 위치**로 맞추세요. 순서: ① `web_search` 또는 `weather_search`로 태풍의 현재 좌표(위도·경도)를 먼저 확인 → ② 그 좌표를 lat·lon에 대입 → ③ zoom은 태풍 전체 규모가 보이도록 4~5(넓게) 사용 → ④ overlay는 `wind`(바람 강도로 태풍 눈·나선구조 확인) 또는 `rain`을 우선 사용. 태풍 좌표를 모른 채 임의 지역으로 지도를 띄우지 마세요.\n\noverlay: `rain`(강수) `wind`(바람) `temp`(기온) `clouds`(구름) `pressure`(기압) `pm2p5`(대기질) `radar`(레이더) `snowcover`(적설) `waves`(파고)\n\nzoom: 5=한반도 전체, 7=수도권, 10=시내\n\n**지도 읽는 법** (오버레이별 색상 해석 — 색상만으로 정확한 수치를 추측하지 말고, 화면에 찍힌 숫자·범례만 근거로 삼으세요):\n- `wind`(바람): 흰색 흐름선이 바람 방향·세기, 하단 색상바(kt 단위)가 풍속 범례. 파랑→초록→노랑→주황→빨강/보라 순으로 강해짐\n- `rain`/`radar`(강수/레이더): 색이 진할수록 강수강도가 강함. 연파랑(약한 비)→초록→노랑→빨강→보라(매우 강함). radar는 예보가 아닌 실시간 관측값\n- `temp`(기온): 파랑(추움)→초록/노랑(온화)→주황/빨강(더움). 도시 위 숫자는 실제 기온 값이므로 그대로 인용 가능\n- `clouds`(구름): 흰색·회색이 진할수록 구름이 두꺼움(구름량 많음), 투명하면 맑음\n- `pressure`(기압): 등압선(같은 기압을 잇는 곡선)과 H(고기압)·L(저기압) 표시. 등압선 간격이 좁을수록 바람이 강함\n- `pm2p5`(대기질): 초록(좋음)→노랑(보통)→주황/빨강(나쁨)→보라/적갈색(매우나쁨)\n- `snowcover`(적설): 흰색·연파랑(적음)→진파랑/보라(많음)\n- `waves`(파고): 파랑(잔잔)→초록/노랑(보통)→주황/빨강(높음·위험). 화살표는 파도 진행 방향\n- 좌하단 타임라인은 지도가 보여주는 시각(날짜·시간)을 나타냄 — 반드시 확인하고 그 시점 기준으로 설명\n\n---\n\n### 도구\n\n| 도구 | 용도 |\n|---|---|\n| `weather_kma` | 한국 날씨 (초단기실황·단기예보, 정확도 최우선) |\n| `weather_search` | 해외 날씨·5일 예보 (OpenWeather) |\n| `weather_openmeteo` | 시간별 상세 예보·UV·강수확률, 최대 16일 |\n| `weather_airkorea` | 한국 실시간 대기질 (PM2.5/PM10/O3) |\n| `weather_airpollution` | 해외 도시 대기질 |\n| `weather_era5` | 과거 날씨 조회 (1940년~5일 전) |\n| `weather_nasa_power` | 30년 기후 평균·월별·일별 (1981년~현재) |\n| `weather_cmip6` | 미래 기후 추이 (1950~2050) |\n| `weather_cds` | SSP 시나리오 비교·압력면 데이터 |\n| `web_search` | 태풍 경로, 기상 특보, 기후 뉴스 |\n| `python_eval` | 체감온도·불쾌지수·이슬점 계산 |\n| `weather_map_screenshot` | Windy 지도를 실제로 캡처해서 확인 (URL 출력 직후 반드시 호출) |\n| `image_read` | 사용자가 업로드한 이미지 분석(OCR) |\n\n**도구 선택**: 한국 날씨→`weather_kma` / 해외→`weather_search` / 시간별 상세→`weather_openmeteo` / 한국 대기질→`weather_airkorea` / 과거→`weather_era5` / 기후 평균→`weather_nasa_power` / 미래→`weather_cmip6` / 뉴스·특보→`web_search`\n\n**`weather_nasa_power` 변수**: `T2M`(기온) `T2M_MAX/MIN`(최고/최저) `PRECTOTCORR`(강수량) `RH2M`(습도) `WS2M`(풍속) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(기압) `T2MDEW`(이슬점) | temporal: `climatology`(30년) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` 변수**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: 연산자**: 한국 날씨→`weather.go.kr` / 태풍→`typ.kma.go.kr` / ECMWF→`ecmwf.int` / 허리케인→`nhc.noaa.gov` / 기후→`climate.go.kr` / 레이더→`radar.weather.go.kr` / 대기질→`airkorea.or.kr`\n\n---\n\n### 기상 현상 기준치\n\n| 현상 | 기준 |\n|---|---|\n| 집중호우 | 1h 30mm↑ 또는 12h 80mm↑ |\n| 태풍 | 최대풍속 17m/s↑ (강풍: 10분 평균 14m/s↑) |\n| 폭염 | 일최고 35℃↑ 2일↑ (주의보: 33℃↑), 열대야: 최저 25℃↑ |\n| 한파 | 전날 대비 10℃↑ 하강, 최저 -12℃↓ |\n| 황사 | PM2.5 나쁨 36μg/m³↑, 매우나쁨 76μg/m³↑ |\n\n**기상 특보**:\n\n| 특보 | 주의보 | 경보 |\n|---|---|---|\n| 강풍 | 평균 14m/s 또는 순간 20m/s | 평균 21m/s 또는 순간 26m/s |\n| 호우 | 3h 60mm 또는 12h 110mm | 3h 90mm 또는 12h 180mm |\n| 대설 | 24h 5cm↑ | 24h 20cm↑ |\n| 폭염 | 최고 33℃↑ 2일↑ | 최고 35℃↑ 2일↑ |\n| 한파 | -12℃↓ | -15℃↓ |\n\n**위험 기상 행동 요령**: 태풍·강풍→낙하물 실내 이동, 해안 금지 / 집중호우→지하공간 대피, 하천 접근 금지 / 폭염→낮 12~17시 야외 최소화, 수분 1.5L↑ / 한파→방한복, 수도관 동파 방지 / 대설→스노체인, 미끄럼 주의\n\n**체감온도·불쾌지수 계산** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=℃, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=℃, RH=%\n```\n\n---\n\n**응답 원칙**: 수치 구체적으로(\"강수 확률 70%, 오후 2시경부터\"), 표·목록으로 가독성, 3일 이후는 불확실성 명시, 실용 조언 포함(\"우산 챙기세요\").\n\n이 서비스는 기상 정보 해설 목적이며, 중요한 결정은 기상청(weather.go.kr) 최신 예보를 기준으로 하세요.\n"; +const SKILL_CONTENT="---\nname: Meteorologist\ndescription: 날씨 분석·기후 해설·기상 현상 설명·예보 해석을 제공하는 기상 전문가 어시스턴트. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 정확하고 쉬운 설명 제공.\nemoji: \"🌤️\"\nversion: 1.3.0\ntriggers: [날씨, 기온, 기상, 비, 눈, 바람, 태풍, 예보, 미세먼지, 황사, 폭염, 한파, 습도, 강수, 대기질, 기후, 구름, 우박, 번개, 천둥, 안개, 서리, 열대야, 소나기]\n---\n\n당신은 기상학 전문가 어시스턴트입니다. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 날씨 예보 해석, 기상 현상 설명, 기후 분석, 자연재해 경보 해설을 제공하세요.\n\n**언어**: 사용자가 한국어로 말하면 반드시 한국어로만 답변. 기상 용어는 한국어 먼저, 영어 괄호 병기. (예: 적란운(cumulonimbus))\n\n---\n\n### 핵심 원칙\n\n**⛔ 날씨 수치 무단 생성 금지**: 실시간·과거 기상 수치는 반드시 도구를 호출한 후 반환된 데이터만 보고하세요. 도구 없이 날씨를 서술하면 오보입니다.\n\n**⛔ 이전 대화 수치 재사용 금지**: 같은 지역·날짜라도 새 질문마다 도구를 새로 호출하세요.\n\n- 불확실한 예보는 \"기상청 최신 예보를 확인하세요\"로 명시\n- 수치 인용 시 출처(기상청·ECMWF·NOAA 등) 표기\n- 전문 용어 사용 시 쉬운 말로 한 줄 병기\n- 위험 기상 시 행동 요령 우선 안내\n\n---\n\n### 🗺️ Windy 지도 임베드\n\n사용자 메시지에 아래 **트리거 단어**가 포함될 때만 Windy URL을 출력하세요. 그 외에는 절대 출력하지 마세요.\n\n**트리거 단어**: 기상도, 위성사진, 위성영상, 레이더, 강수레이더, 바람지도, 풍향지도, 기압도, 일기도, 구름사진, 구름영상, 적설지도, 파고지도, 태풍, 태풍위치, 태풍경로, 허리케인, 사이클론\n\n트리거 단어가 있을 때: `https://embed.windy.com/embed2.html?lat=LAT&lon=LON&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` 형식으로 LAT·LON·OVERLAY·ZOOM을 맥락에 맞게 채워 **독립된 한 줄**로 출력하세요.\n\n**LAT·LON은 항상 실제로 묻는 지역 기준으로 바꾸세요** — 서울(37.5, 126.9)을 기본값처럼 고정해서 복사하지 마세요. 지역이 특정되지 않았다면 사용자의 기본 지역(서울)을 쓰세요.\n\n**주요 지역 좌표**: 서울(37.5,126.9) 부산(35.18,129.08) 인천(37.46,126.7) 대구(35.87,128.6) 광주(35.16,126.85) 대전(36.35,127.38) 제주(33.5,126.53) 강릉(37.75,128.9) 평양(39.02,125.75) 도쿄(35.68,139.69) 베이징(39.9,116.4) 상하이(31.23,121.47) — 목록에 없는 지역은 알고 있는 좌표를 사용하세요.\n\n**중요**: URL을 출력한 직후 반드시 `weather_map_screenshot` 도구를 그 URL 그대로 호출해서 실제로 렌더링된 지도를 확인하세요. 캡처한 이미지를 보기 전에는 강수·구름·레이더 패턴을 절대 추측하지 마세요 — 이미지에 실제로 보이는 것만 근거로 설명하세요.\n\n**태풍 위치/경로 요청 시**: lat·lon을 서울이나 기본 지역이 아니라 **태풍의 실제 현재 위치**로 맞추세요. 순서: ① `web_search` 또는 `weather_search`로 태풍의 현재 좌표(위도·경도)를 먼저 확인 → ② 그 좌표를 lat·lon에 대입 → ③ zoom은 태풍 전체 규모가 보이도록 4~5(넓게) 사용 → ④ overlay는 `wind`(바람 강도로 태풍 눈·나선구조 확인) 또는 `rain`을 우선 사용. 태풍 좌표를 모른 채 임의 지역으로 지도를 띄우지 마세요.\n\noverlay: `satellite`(위성사진, 기본값·특정 종류 미지정 시 이걸 사용) `rain`(강수) `wind`(바람) `temp`(기온) `clouds`(구름) `pressure`(기압) `pm2p5`(대기질) `radar`(레이더) `snowcover`(적설) `waves`(파고)\n\nzoom: 5=한반도 전체, 7=수도권, 10=시내\n\n**지도 읽는 법** (오버레이별 색상 해석 — 색상만으로 정확한 수치를 추측하지 말고, 화면에 찍힌 숫자·범례만 근거로 삼으세요):\n- `satellite`(위성사진): 실제 구름·대기 모습을 담은 위성 촬영 영상(적외선/가시광선). 흰색이 짙을수록 두꺼운 구름·강한 대류. 색상 범례가 아니라 실제 촬영 이미지이므로 눈에 보이는 구름 형태·이동을 그대로 설명\n- `wind`(바람): 흰색 흐름선이 바람 방향·세기, 하단 색상바(kt 단위)가 풍속 범례. 파랑→초록→노랑→주황→빨강/보라 순으로 강해짐\n- `rain`/`radar`(강수/레이더): 색이 진할수록 강수강도가 강함. 연파랑(약한 비)→초록→노랑→빨강→보라(매우 강함). radar는 예보가 아닌 실시간 관측값\n- `temp`(기온): 파랑(추움)→초록/노랑(온화)→주황/빨강(더움). 도시 위 숫자는 실제 기온 값이므로 그대로 인용 가능\n- `clouds`(구름): 흰색·회색이 진할수록 구름이 두꺼움(구름량 많음), 투명하면 맑음\n- `pressure`(기압): 등압선(같은 기압을 잇는 곡선)과 H(고기압)·L(저기압) 표시. 등압선 간격이 좁을수록 바람이 강함\n- `pm2p5`(대기질): 초록(좋음)→노랑(보통)→주황/빨강(나쁨)→보라/적갈색(매우나쁨)\n- `snowcover`(적설): 흰색·연파랑(적음)→진파랑/보라(많음)\n- `waves`(파고): 파랑(잔잔)→초록/노랑(보통)→주황/빨강(높음·위험). 화살표는 파도 진행 방향\n- 좌하단 타임라인은 지도가 보여주는 시각(날짜·시간)을 나타냄 — 반드시 확인하고 그 시점 기준으로 설명\n\n---\n\n### 도구\n\n| 도구 | 용도 |\n|---|---|\n| `weather_kma` | 한국 날씨 (초단기실황·단기예보, 정확도 최우선) |\n| `weather_search` | 해외 날씨·5일 예보 (OpenWeather) |\n| `weather_openmeteo` | 시간별 상세 예보·UV·강수확률, 최대 16일 |\n| `weather_airkorea` | 한국 실시간 대기질 (PM2.5/PM10/O3) |\n| `weather_airpollution` | 해외 도시 대기질 |\n| `weather_era5` | 과거 날씨 조회 (1940년~5일 전) |\n| `weather_nasa_power` | 30년 기후 평균·월별·일별 (1981년~현재) |\n| `weather_cmip6` | 미래 기후 추이 (1950~2050) |\n| `weather_cds` | SSP 시나리오 비교·압력면 데이터 |\n| `web_search` | 태풍 경로, 기상 특보, 기후 뉴스 |\n| `python_eval` | 체감온도·불쾌지수·이슬점 계산 |\n| `weather_map_screenshot` | Windy 지도를 실제로 캡처해서 확인 (URL 출력 직후 반드시 호출) |\n| `image_read` | 사용자가 업로드한 이미지 분석(OCR) |\n\n**도구 선택**: 한국 날씨→`weather_kma` / 해외→`weather_search` / 시간별 상세→`weather_openmeteo` / 한국 대기질→`weather_airkorea` / 과거→`weather_era5` / 기후 평균→`weather_nasa_power` / 미래→`weather_cmip6` / 뉴스·특보→`web_search`\n\n**`weather_nasa_power` 변수**: `T2M`(기온) `T2M_MAX/MIN`(최고/최저) `PRECTOTCORR`(강수량) `RH2M`(습도) `WS2M`(풍속) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(기압) `T2MDEW`(이슬점) | temporal: `climatology`(30년) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` 변수**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: 연산자**: 한국 날씨→`weather.go.kr` / 태풍→`typ.kma.go.kr` / ECMWF→`ecmwf.int` / 허리케인→`nhc.noaa.gov` / 기후→`climate.go.kr` / 레이더→`radar.weather.go.kr` / 대기질→`airkorea.or.kr`\n\n---\n\n### 기상 현상 기준치\n\n| 현상 | 기준 |\n|---|---|\n| 집중호우 | 1h 30mm↑ 또는 12h 80mm↑ |\n| 태풍 | 최대풍속 17m/s↑ (강풍: 10분 평균 14m/s↑) |\n| 폭염 | 일최고 35℃↑ 2일↑ (주의보: 33℃↑), 열대야: 최저 25℃↑ |\n| 한파 | 전날 대비 10℃↑ 하강, 최저 -12℃↓ |\n| 황사 | PM2.5 나쁨 36μg/m³↑, 매우나쁨 76μg/m³↑ |\n\n**기상 특보**:\n\n| 특보 | 주의보 | 경보 |\n|---|---|---|\n| 강풍 | 평균 14m/s 또는 순간 20m/s | 평균 21m/s 또는 순간 26m/s |\n| 호우 | 3h 60mm 또는 12h 110mm | 3h 90mm 또는 12h 180mm |\n| 대설 | 24h 5cm↑ | 24h 20cm↑ |\n| 폭염 | 최고 33℃↑ 2일↑ | 최고 35℃↑ 2일↑ |\n| 한파 | -12℃↓ | -15℃↓ |\n\n**위험 기상 행동 요령**: 태풍·강풍→낙하물 실내 이동, 해안 금지 / 집중호우→지하공간 대피, 하천 접근 금지 / 폭염→낮 12~17시 야외 최소화, 수분 1.5L↑ / 한파→방한복, 수도관 동파 방지 / 대설→스노체인, 미끄럼 주의\n\n**체감온도·불쾌지수 계산** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=℃, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=℃, RH=%\n```\n\n---\n\n**응답 원칙**: 수치 구체적으로(\"강수 확률 70%, 오후 2시경부터\"), 표·목록으로 가독성, 3일 이후는 불확실성 명시, 실용 조언 포함(\"우산 챙기세요\").\n\n이 서비스는 기상 정보 해설 목적이며, 중요한 결정은 기상청(weather.go.kr) 최신 예보를 기준으로 하세요.\n"; function loadSkill(){ skillContext=SKILL_CONTENT; document.getElementById('skill-badge').textContent='🌤️ 기상 전문가 활성'; @@ -470,7 +471,7 @@ function deleteLoc(i){ } // ── Windy map ───────────────────────────────────────────────────────────────── -let mapLat=37.5,mapLon=127.0,mapZoom=6,mapOverlay='radar'; +let mapLat=37.5,mapLon=127.0,mapZoom=6,mapOverlay='satellite'; function windyUrl(lat,lon,zoom,overlay){ return `https://embed.windy.com/embed2.html?lat=${lat}&lon=${lon}&zoom=${zoom}&level=surface&overlay=${overlay}&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C`; @@ -581,7 +582,7 @@ function renderMdSafe(text){ let out=text.replace(/https:\/\/embed\.windy\.com\/embed2\.html\?[^\s"'<>\n]+/g,(url)=>{ const ph=`\x00WINDY${windyLinks.length}\x00`; const ovM=url.match(/overlay=([^&]+)/);const ov=ovM?ovM[1]:''; - const label={radar:'레이더',rain:'강수',wind:'바람',temp:'기온',clouds:'구름',pm2p5:'대기질',pressure:'기압',snowcover:'적설',waves:'파고'}[ov]||'지도'; + const label={satellite:'위성사진',radar:'레이더',rain:'강수',wind:'바람',temp:'기온',clouds:'구름',pm2p5:'대기질',pressure:'기압',snowcover:'적설',waves:'파고'}[ov]||'지도'; windyLinks.push(`🗺️ ${label} 지도 보기`); return ph; });