From d74614cee18e29f3cd855708de099777f1c1f26d Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 26 Jun 2026 11:15:27 +0900 Subject: [PATCH] =?UTF-8?q?v3.2.0:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20+=20=ED=83=90=EC=A0=95=EC=95=B1=20?= =?UTF-8?q?=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20+=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=EB=8C=80=EC=9D=91=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회) - 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts) - 채널 설정 UI + userMap으로 다중 사용자 계정 매핑 - 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성 - 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응) - 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000) - 교통앱 마지막 선택 지역 localStorage 저장/복원 - docs/kakao-setup.md 작성 Co-Authored-By: Claude Sonnet 4.6 --- .../users/papa/workspace/prompts/USER.md | 22 +- README.md | 7 + docs/kakao-setup.md | 103 ++ src/gateway/kakao-channel.ts | 211 +++ src/gateway/kakao-notify.ts | 148 +++ src/gateway/server-v2.ts | 390 +++++- web-ui/accountant-app.html | 46 +- web-ui/app.js | 53 +- web-ui/arduino-emulator.html | 53 + web-ui/code.html | 75 ++ web-ui/dental-agent.html | 4 +- web-ui/detective-app.html | 1180 +++++++++++++++++ web-ui/esp32-flasher.html | 54 + web-ui/index.html | 37 + web-ui/investor-app.html | 93 +- web-ui/language-app.html | 4 +- web-ui/lawyer-app.html | 46 +- web-ui/mind-app.html | 46 +- web-ui/music-app.html | 802 ++++++++++- web-ui/pptx-wizard.html | 50 +- web-ui/styles.css | 2 +- web-ui/traffic-app.html | 335 +++++ web-ui/weather-app.html | 122 +- 23 files changed, 3761 insertions(+), 122 deletions(-) create mode 100644 docs/kakao-setup.md create mode 100644 src/gateway/kakao-channel.ts create mode 100644 src/gateway/kakao-notify.ts create mode 100644 web-ui/detective-app.html create mode 100644 web-ui/traffic-app.html diff --git a/.smallclaw/users/papa/workspace/prompts/USER.md b/.smallclaw/users/papa/workspace/prompts/USER.md index 58fb8ef..a272029 100644 --- a/.smallclaw/users/papa/workspace/prompts/USER.md +++ b/.smallclaw/users/papa/workspace/prompts/USER.md @@ -1,25 +1,17 @@ -# USER.md — About My Human - -> SmallClaw builds this file over time. Categories are created automatically as new things are learned. -> Use memory_browse("user") to see categories, memory_write to add facts, memory_read("user") for the full file. - ---- +# USER.md ## identity -- Name: [ Users Name ] - Platform: Linux - Stack: TypeScript / Node.js - Version control: Git -## goal - - ## investing_interest -- SpaceX (SPCX) listed on NASDAQ on June 12, 2026, at $161, with a $2T market cap. User is tracking this. [2026-06-13] +- SpaceX (SPCX) listed on NASDAQ on June 12, 2026, at $161, with a $2T market cap. [2026-06-13] +- Tracking XLK (Technology Select Sector SPDR Fund). [2026-06-21] +- Always use latest real-time data for stock prices. [2026-06-24] -- User is tracking XLK (Technology Select Sector SPDR Fund) as of June 21, 2026. [2026-06-21] ## ai_models -- Gemma 4 was released in March 2026 (12B version in June 2026) with native audio and video support, allowing direct multimodal processing on local hardware. [2026-06-21] ---- +- Gemma 4 released March 2026 (12B in June 2026), native audio/video multimodal. [2026-06-21] -*No other categories yet — SmallClaw will add them as it learns more.* \ No newline at end of file +## hardware_history +- RTX 5060 launched May 19, 2025 ($299); RTX 5060 Ti launched April 16, 2025 ($429 for 16GB). [2026-06-24] \ No newline at end of file diff --git a/README.md b/README.md index 4d94df4..a0d1d92 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,13 @@ SmallClaw integrates with messaging platforms: - Allowed user ID allowlist - Streaming or partial response modes +### 카카오톡 +- 오픈빌더 챗봇 웹훅 — 채널 가입자 누구나 1:1 AI 대화 +- 비동기 콜백 방식으로 오픈빌더 5초 제한 우회 +- "나에게 보내기" OAuth — 서버 이벤트를 본인 카카오톡으로 수신 +- userId → SmallClaw 계정 유저맵으로 다중 사용자 지원 +- 자세한 설정: [docs/kakao-setup.md](docs/kakao-setup.md) + ### Discord - Webhook-based integration - Bot token + guild/channel configuration diff --git a/docs/kakao-setup.md b/docs/kakao-setup.md new file mode 100644 index 0000000..98d89df --- /dev/null +++ b/docs/kakao-setup.md @@ -0,0 +1,103 @@ +# 카카오톡 연동 가이드 + +SmallClaw를 카카오톡 채널 챗봇 + "나에게 보내기" 알림으로 연동하는 방법입니다. + +## 구조 + +``` +사용자 → 카카오톡 채널 → 카카오 i 오픈빌더 → SmallClaw 서버 + ↓ + AI 처리 (비동기) + ↓ + 카카오 callbackUrl로 응답 +``` + +- **REST API 키** — 앱 하나에 하나, 서버에 한 번만 설정 +- **채널 주소** — 하나, 모든 사용자가 동일한 주소 사용 +- **userId** — 카카오가 사용자마다 다른 값을 웹훅에 포함하여 전송 + +--- + +## 1단계: 카카오 개발자 앱 설정 + +1. [developers.kakao.com](https://developers.kakao.com) → **내 애플리케이션 → 앱 추가** +2. **앱 키 → REST API 키** 복사 +3. **카카오 로그인 → 활성화** ON +4. **카카오 로그인 → 동의항목** → `talk_message` 필수 동의 활성화 +5. **카카오 로그인 → Redirect URI** 추가: + ``` + https://{서버주소}/api/kakao/auth/callback + ``` +6. **플랫폼 → Web** → 사이트 도메인 등록 + +--- + +## 2단계: SmallClaw 채널 설정 + +1. SmallClaw 웹 UI → **설정 → 채널 → 카카오톡** 선택 +2. **REST API 키** 입력 +3. **🟡 카카오 로그인** 버튼 클릭 → 브라우저 팝업에서 로그인 및 동의 +4. **챗봇 채널 활성화** 체크 +5. **저장** + +--- + +## 3단계: 카카오 i 오픈빌더 설정 + +1. [i.kakao.com](https://i.kakao.com) → 챗봇 생성 +2. **스킬** → **생성** → URL 입력: + ``` + https://{서버주소}/api/kakao/webhook + ``` +3. 스킬 편집 → **대기 메시지** 입력 (예: `잠시 기다려 주세요...`) +4. **시나리오 → 폴백 블록 → 봇 응답 → 스킬데이터 사용** → 위 스킬 선택 +5. **배포** + +--- + +## 4단계: 카카오톡 채널 생성 + +1. [center-pf.kakao.com](https://center-pf.kakao.com) → 채널 생성 +2. **채널 관리 → 1:1 채팅 활성화** +3. 오픈빌더 → **배포 → 운영채널** 에서 이 채널 연결 + +--- + +## 사용자 계정 매핑 + +채널 주소는 하나지만 SmallClaw 계정별로 연결 가능합니다. + +1. 사용자가 채널(`pf.kakao.com/_{채널ID}/chat`)에서 메시지 전송 +2. 서버 로그에서 userId 확인: + ``` + [KakaoChannel] 웹훅 수신 userId=f4e1b2324f39... + ``` +3. SmallClaw 설정 → 채널 → 카카오톡 → **사용자 매핑 (userMap)** 에 입력: + ```json + { + "f4e1b2324f39...": "papa", + "다른userId": "cherry" + } + ``` +4. 저장 + +매핑되지 않은 userId는 기본 admin 계정으로 연결됩니다. + +--- + +## "나에게 보내기" 알림 + +OAuth 로그인 완료 후 서버 이벤트(AI 응답, 시스템 알림 등)를 본인 카카오톡으로 수신할 수 있습니다. + +채널 설정 → **테스트 전송** 버튼으로 동작 확인 가능합니다. + +--- + +## 동작 원리 (콜백 방식) + +오픈빌더의 5초 제한을 우회하기 위해 비동기 콜백 방식을 사용합니다: + +1. 카카오가 웹훅 요청 전송 (`callbackUrl` 포함) +2. SmallClaw가 즉시 `{ useCallback: true }` 응답 +3. 백그라운드에서 AI 처리 +4. 처리 완료 후 `callbackUrl`로 응답 전송 (최대 5분 유효) diff --git a/src/gateway/kakao-channel.ts b/src/gateway/kakao-channel.ts new file mode 100644 index 0000000..9a05766 --- /dev/null +++ b/src/gateway/kakao-channel.ts @@ -0,0 +1,211 @@ +/** + * kakao-channel.ts — 카카오 i 오픈빌더 챗봇 채널 + * + * Telegram long-polling 의 카카오 대체재. + * 오픈빌더 스킬 서버(웹훅)로 메시지를 수신하고, + * AI 응답은 callbackUrl 비동기 전송으로 5초 제한을 우회한다. + * + * ─── 초기 설정 순서 ────────────────────────────────────────────────── + * 1. i.kakao.com → 챗봇 생성 + * 2. 스킬 서버 URL: https://{서버주소}/api/kakao/webhook + * 3. 스킬 편집 → "콜백 사용" 체크 + * 4. 시나리오 → 폴백 블록 → 스킬 연결 + * + * ─── Vault 키 ───────────────────────────────────────────────────────── + * channels.kakao.userMap — JSON { "카카오userId": "homeclaw_username" } + * + * ─── 플로우 ─────────────────────────────────────────────────────────── + * 사용자 → 카카오채널 → 오픈빌더 → POST /api/kakao/webhook + * → 즉시 { useCallback: true } 응답 (5초 제한 통과) + * → 백그라운드에서 AI 처리 + * → POST callbackUrl 로 최종 응답 전송 + */ + +export interface KakaoChannelConfig { + enabled: boolean; + /** { "카카오userId": "homeclaw_username" } */ + userMap: Record; +} + +export interface KakaoChannelDeps { + handleChat: ( + message: string, + sessionId: string, + sendSSE: (event: string, data: any) => void, + pinnedMessages?: Array<{ role: string; content: string }>, + abortSignal?: { aborted: boolean }, + callerContext?: string, + modelOverride?: string, + executionMode?: 'interactive' | 'background_task' | 'heartbeat' | 'cron', + username?: string, + ) => Promise<{ type: string; text: string; thinking?: string }>; + addMessage: ( + sessionId: string, + msg: { role: 'user' | 'assistant'; content: string; timestamp: number }, + options?: { deferOnMemoryFlush?: boolean }, + username?: string, + ) => void; + getIsModelBusy: () => boolean; + defaultUsername?: string | (() => string | undefined); +} + +// ─── 오픈빌더 웹훅 요청 타입 ───────────────────────────────────────────────── + +interface OpenBuilderRequest { + userRequest: { + user: { id: string; properties?: Record }; + utterance: string; + callbackUrl?: string; + }; + bot: { id: string; name: string }; +} + +// ─── 오픈빌더 응답 타입 ─────────────────────────────────────────────────────── + +interface SimpleTextOutput { + simpleText: { text: string }; +} + +interface OpenBuilderResponse { + version: '2.0'; + useCallback?: boolean; + template?: { outputs: SimpleTextOutput[] }; +} + +// ─── KakaoChannel ───────────────────────────────────────────────────────────── + +export class KakaoChannel { + private config: KakaoChannelConfig; + private deps: KakaoChannelDeps; + + constructor(config: KakaoChannelConfig, deps: KakaoChannelDeps) { + this.config = config; + this.deps = deps; + } + + get enabled(): boolean { + return this.config.enabled; + } + + updateConfig(config: Partial): void { + Object.assign(this.config, config); + } + + /** 웹훅 핸들러. Express route에서 호출. */ + async handleWebhook(body: OpenBuilderRequest): Promise { + const userId = body.userRequest?.user?.id; + const utterance = (body.userRequest?.utterance || '').trim(); + const callbackUrl = body.userRequest?.callbackUrl; + console.log(`[KakaoChannel] body=${JSON.stringify(body)}`); + console.log(`[KakaoChannel] callbackUrl=${callbackUrl || '없음(동기모드)'} utterance="${utterance}"`); + + if (!utterance) { + return makeSimpleResponse('메시지를 입력해 주세요.'); + } + + const username = this.resolveUsername(userId); + const sessionId = `kakao_${userId}`; + + if (callbackUrl) { + // 비동기 모드: 즉시 수락 → 백그라운드 AI 처리 → callbackUrl로 응답 + this.processAsync(utterance, sessionId, username, callbackUrl).catch((err) => + console.error('[KakaoChannel] async error:', err), + ); + return { version: '2.0', useCallback: true }; + } + + // 동기 모드 (callbackUrl 없음) — 4.5초 안에 응답해야 타임아웃 방지 + const timeout = new Promise((resolve) => + setTimeout(() => resolve('처리 시간이 초과됐습니다. 잠시 후 다시 질문해 주세요.'), 4500), + ); + const result = await Promise.race([this.runAI(utterance, sessionId, username), timeout]); + return makeSimpleResponse(result); + } + + private async processAsync( + utterance: string, + sessionId: string, + username: string | undefined, + callbackUrl: string, + ): Promise { + console.log(`[KakaoChannel] 비동기 처리 시작 callbackUrl=${callbackUrl}`); + const text = await this.runAI(utterance, sessionId, username); + console.log(`[KakaoChannel] AI 응답 완료 (${text.length}자) → callbackUrl로 전송`); + await sendCallback(callbackUrl, makeSimpleResponse(text)); + } + + private async runAI( + message: string, + sessionId: string, + username: string | undefined, + ): Promise { + let fullText = ''; + const sendSSE = (_event: string, data: any) => { + if (data?.type === 'token' || data?.type === 'text_delta') { + fullText += data.text || ''; + } + }; + + try { + this.deps.addMessage( + sessionId, + { role: 'user', content: message, timestamp: Date.now() }, + {}, + username, + ); + const result = await this.deps.handleChat( + message, + sessionId, + sendSSE, + undefined, + undefined, + 'kakao', + undefined, + 'interactive', + username, + ); + const text = result?.text || fullText || '응답을 생성하지 못했습니다.'; + this.deps.addMessage( + sessionId, + { role: 'assistant', content: text, timestamp: Date.now() }, + {}, + username, + ); + return text; + } catch (err) { + console.error('[KakaoChannel] AI error:', err); + return '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'; + } + } + + private resolveUsername(kakaoUserId: string): string | undefined { + if (this.config.userMap?.[kakaoUserId]) return this.config.userMap[kakaoUserId]; + const df = this.deps.defaultUsername; + if (typeof df === 'function') return df(); + return df; + } +} + +// ─── 헬퍼 ──────────────────────────────────────────────────────────────────── + +function makeSimpleResponse(text: string): OpenBuilderResponse { + return { + version: '2.0', + template: { + outputs: [{ simpleText: { text: text.slice(0, 2000) } }], + }, + }; +} + +async function sendCallback(url: string, body: OpenBuilderResponse): Promise { + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + console.log(`[KakaoChannel] callback 전송 완료 status=${resp.status}`); + } catch (err) { + console.error('[KakaoChannel] callback 전송 실패:', err); + } +} diff --git a/src/gateway/kakao-notify.ts b/src/gateway/kakao-notify.ts new file mode 100644 index 0000000..21c70fb --- /dev/null +++ b/src/gateway/kakao-notify.ts @@ -0,0 +1,148 @@ +/** + * kakao-notify.ts — 카카오 "나에게 보내기" 푸시 알림 + * + * Telegram sendToAllowed() 의 카카오 대체재. + * 사용자 OAuth 액세스 토큰으로 본인 카카오톡에 메시지를 보낸다. + * + * ─── 초기 설정 순서 ─────────────────────────────────────────────────── + * 1. developers.kakao.com → 앱 생성 → REST API 키 복사 + * 2. 앱 설정 → 카카오 로그인 활성화 + * 3. 허용된 리다이렉트 URI에 http://localhost:{port}/api/kakao/auth/callback 추가 + * 4. GET /api/kakao/auth/start 로 OAuth 로그인 → 리프레시 토큰 자동 저장 + * + * ─── Vault 키 ────────────────────────────────────────────────────────── + * channels.kakao.appKey — REST API 키 (필수) + * channels.kakao.refreshToken — OAuth 리프레시 토큰 (OAuth 후 자동 저장) + */ + +const KAKAO_TOKEN_URL = 'https://kauth.kakao.com/oauth/token'; +const KAKAO_MEMO_URL = 'https://kapi.kakao.com/v2/api/talk/memo/default/send'; + +export interface KakaoNotifyConfig { + appKey: string; + refreshToken: string; + /** 리프레시 토큰이 갱신됐을 때 vault에 저장하는 콜백 */ + onTokenRefreshed?: (newRefreshToken: string) => void; +} + +interface TokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + refresh_token_expires_in?: number; +} + +export class KakaoNotify { + private appKey: string; + private refreshToken: string; + private accessToken: string = ''; + private tokenExpiry: number = 0; + private onTokenRefreshed?: (t: string) => void; + + constructor(config: KakaoNotifyConfig) { + this.appKey = config.appKey; + this.refreshToken = config.refreshToken; + this.onTokenRefreshed = config.onTokenRefreshed; + } + + get enabled(): boolean { + return !!(this.appKey && this.refreshToken); + } + + /** OAuth 완료 후 실행 중인 인스턴스에 토큰을 즉시 반영 */ + updateTokens(refreshToken: string, accessToken?: string): void { + if (refreshToken) this.refreshToken = refreshToken; + if (accessToken) { this.accessToken = accessToken; this.tokenExpiry = Date.now() + 3600_000; } + } + + /** 카카오톡 "나에게 보내기". 2000자 초과 시 자동 분할 전송. */ + async send(text: string): Promise { + if (!this.enabled) return; + const chunks = splitText(text, 1900); + for (const chunk of chunks) { + await this.sendChunk(chunk); + } + } + + private async sendChunk(text: string): Promise { + await this.ensureAccessToken(); + + const templateObject = JSON.stringify({ + object_type: 'text', + text, + link: { web_url: '', mobile_web_url: '' }, + }); + + const resp = await fetch(KAKAO_MEMO_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ template_object: templateObject }), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + // 401 = 토큰 만료 → 재시도 1회 + if (resp.status === 401) { + this.accessToken = ''; + this.tokenExpiry = 0; + await this.ensureAccessToken(); + const retry = await fetch(KAKAO_MEMO_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ template_object: templateObject }), + }); + if (!retry.ok) throw new Error(`KakaoNotify retry failed: ${retry.status}`); + return; + } + throw new Error(`KakaoNotify send failed: ${resp.status} ${body}`); + } + } + + private async ensureAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) return; + await this.doRefresh(); + } + + private async doRefresh(): Promise { + const resp = await fetch(KAKAO_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.appKey, + refresh_token: this.refreshToken, + }), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`KakaoNotify token refresh failed: ${resp.status} ${body}`); + } + + const data = (await resp.json()) as TokenResponse; + this.accessToken = data.access_token; + this.tokenExpiry = Date.now() + data.expires_in * 1000; + + if (data.refresh_token && data.refresh_token !== this.refreshToken) { + this.refreshToken = data.refresh_token; + this.onTokenRefreshed?.(data.refresh_token); + } + } +} + +function splitText(text: string, maxLen: number): string[] { + if (text.length <= maxLen) return [text]; + const chunks: string[] = []; + let i = 0; + while (i < text.length) { + chunks.push(text.slice(i, i + maxLen)); + i += maxLen; + } + return chunks; +} diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 8d69e5c..0c861b6 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -95,6 +95,8 @@ import { recordAgentRun, } from '../scheduler'; import { TelegramChannel } from './telegram-channel'; +import { KakaoNotify } from './kakao-notify'; +import { KakaoChannel } from './kakao-channel'; import { OrchestrationTriggerState, callSecondaryPreflight, @@ -704,6 +706,7 @@ type ChannelsConfig = { telegram: TelegramChannelConfig; discord: DiscordChannelConfig; whatsapp: WhatsAppChannelConfig; + kakao: { appKey: string; refreshToken: string; userMap: Record; channelEnabled: boolean }; }; // HIGH-01 fix: resolve vault references when normalizing channel configs. @@ -767,10 +770,17 @@ function normalizeWhatsAppConfig(raw: any): WhatsAppChannelConfig { function resolveChannelsConfig(): ChannelsConfig { const cfg = getConfig().getConfig() as any; const channels = cfg.channels || {}; + const vault = getVault(CONFIG_DIR_PATH); return { telegram: normalizeTelegramConfig(channels.telegram || {}), discord: normalizeDiscordConfig(channels.discord || {}), whatsapp: normalizeWhatsAppConfig(channels.whatsapp || {}), + kakao: { + appKey: resolveToken(channels.kakao?.appKey) || vault.get('channels.kakao.appKey', 'kakao:init')?.expose() || '', + refreshToken: resolveToken(channels.kakao?.refreshToken) || vault.get('channels.kakao.refreshToken', 'kakao:init')?.expose() || '', + userMap: channels.kakao?.userMap || {}, + channelEnabled: channels.kakao?.channelEnabled === true, + }, }; } @@ -900,6 +910,34 @@ const telegramChannel = new TelegramChannel( } ); +// ─── Kakao 채널 초기화 ──────────────────────────────────────────────────────── +const _kakaoConf = resolveChannelsConfig().kakao; +const kakaoNotify = new KakaoNotify({ + appKey: _kakaoConf.appKey, + refreshToken: _kakaoConf.refreshToken, + onTokenRefreshed: (token) => { + getVault(CONFIG_DIR_PATH).set('channels.kakao.refreshToken', token, 'kakao:token'); + console.log('[KakaoNotify] Refresh token updated in vault.'); + }, +}); +const kakaoChannel = new KakaoChannel( + { enabled: _kakaoConf.channelEnabled, userMap: _kakaoConf.userMap }, + { + handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => + handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), + addMessage, + getIsModelBusy: () => checkIsModelBusy(), + defaultUsername: () => { + const users = listUsers(); + for (const u of users) { + const roleEntry = getUserField(u, 'role'); + if (roleEntry && roleEntry.expose() === 'admin') return u; + } + return users[0]; + }, + }, +); + // ─── Per-user Telegram bot manager ──────────────────────────────────────────── // Each user can have their own dedicated Telegram bot (bot token stored in vault). // Vault key: gateway.auth.users.{username}.channels.telegram.botToken @@ -1474,10 +1512,10 @@ async function buildPersonalityContext( const isAutonomous = executionMode === 'background_task' || executionMode === 'cron' || executionMode === 'heartbeat'; if (isAutonomous) { const parts = [ - loadFile('IDENTITY.md', 600) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 600)}` : '', - loadFile('SOUL.md', 1500) ? `[SOUL]\n${loadFile('SOUL.md', 1500)}` : '', - loadFile('USER.md', 1000) ? `[USER]\n${loadFile('USER.md', 1000)}` : '', - loadFile('AGENTS.md', 1500) ? `[AGENTS]\n${loadFile('AGENTS.md', 1500)}` : '', + loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '', + loadFile('SOUL.md', 4000) ? `[SOUL]\n${loadFile('SOUL.md', 4000)}` : '', + loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '', + loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '', intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '', ].filter(Boolean); await hookBus.fire({ type: 'agent:bootstrap', sessionId, workspacePath, bootstrapFiles: [], timestamp: Date.now() }); @@ -1486,9 +1524,9 @@ async function buildPersonalityContext( if (historyLength === 0) { const parts = [ - loadFile('IDENTITY.md', 600) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 600)}` : '', - loadFile('USER.md', 1000) ? `[USER]\n${loadFile('USER.md', 1000)}` : '', - loadFile('AGENTS.md', 1500) ? `[AGENTS]\n${loadFile('AGENTS.md', 1500)}` : '', + loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '', + loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '', + loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '', intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '', ].filter(Boolean); await hookBus.fire({ type: 'agent:bootstrap', sessionId, workspacePath, bootstrapFiles: [], timestamp: Date.now() }); @@ -1525,15 +1563,15 @@ async function buildPersonalityContext( const toolsHint = isComplex ? `\nFor full tool reference: coder_read_file prompts/TOOLS.md` : ''; // Self.md for debug (cached with fileCache) - const self = cats.has('debug') ? loadFile('SELF.md', 2000) : ''; + const self = cats.has('debug') ? loadFile('SELF.md', 4000) : ''; // SOUL only when tool categories are detected — pure conversational turns don't need it - const soulContent = cats.size > 0 ? loadFile('SOUL.md', 1500) : ''; + const soulContent = cats.size > 0 ? loadFile('SOUL.md', 4000) : ''; const parts = [ - loadFile('IDENTITY.md', 600) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 600)}` : '', - loadFile('USER.md', 1000) ? `[USER]\n${loadFile('USER.md', 1000)}` : '', - loadFile('AGENTS.md', 1500) ? `[AGENTS]\n${loadFile('AGENTS.md', 1500)}` : '', + loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '', + loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '', + loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '', soulContent ? `[SOUL]\n${soulContent}` : '', intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '', toolBlockParts.length > 0 ? `[TOOLS]\n${toolBlockParts.join('\n\n')}${toolsHint}` : (toolsHint ? `[TOOLS]${toolsHint}` : ''), @@ -8733,6 +8771,35 @@ interface SessionInfo { } const activeSessions = new Map(); +const SESSIONS_FILE = path.join(CONFIG_DIR_PATH, 'active-sessions.json'); +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function saveActiveSessions(): void { + try { + const obj: Record = {}; + for (const [k, v] of activeSessions) obj[k] = v; + const tmp = SESSIONS_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(obj), 'utf-8'); + fs.renameSync(tmp, SESSIONS_FILE); + } catch { /* non-fatal */ } +} + +function loadActiveSessions(): void { + try { + if (!fs.existsSync(SESSIONS_FILE)) return; + const raw = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')); + const now = Date.now(); + for (const [token, info] of Object.entries(raw) as [string, SessionInfo][]) { + if (now - (info.createdAt || 0) < SESSION_MAX_AGE_MS) { + activeSessions.set(token, info); + } + } + if (activeSessions.size > 0) console.log(`[Auth] Restored ${activeSessions.size} active session(s)`); + } catch { /* non-fatal */ } +} + +loadActiveSessions(); + function hashPassword(password: string, salt?: string): { hash: string; salt: string } { const s = salt || crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(password, s, 64).toString('hex'); @@ -8973,6 +9040,7 @@ app.post('/api/auth/setup', (req, res) => { setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); + saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username, role: 'admin', token: sessionToken }); } @@ -9003,6 +9071,7 @@ app.post('/api/auth/setup', (req, res) => { setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); + saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username, role: 'admin', token: sessionToken }); } @@ -9024,6 +9093,7 @@ app.post('/api/auth/setup', (req, res) => { setAuthMultiUser(true); const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() }); + saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); res.json({ success: true, username, role: 'admin', token: sessionToken }); }); @@ -9046,6 +9116,7 @@ app.post('/api/auth/login', (req, res) => { } const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username: 'legacy', role: 'admin', createdAt: Date.now() }); + saveActiveSessions(); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); return res.json({ success: true, username: 'legacy', role: 'admin', needsMigration: true, token: sessionToken }); } @@ -9063,6 +9134,7 @@ app.post('/api/auth/login', (req, res) => { } const sessionToken = crypto.randomBytes(32).toString('hex'); activeSessions.set(sessionToken, { username, role, createdAt: Date.now() }); + saveActiveSessions(); skillsManager.initUserSkillsState(getUserWorkspace(username)); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`); res.json({ success: true, username, role, token: sessionToken }); @@ -9070,7 +9142,7 @@ app.post('/api/auth/login', (req, res) => { app.post('/api/auth/logout', (req, res) => { const token = extractAuthToken(req); - if (token) activeSessions.delete(token); + if (token) { activeSessions.delete(token); saveActiveSessions(); } res.setHeader('Set-Cookie', `${AUTH_COOKIE}=; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=0`); res.json({ success: true }); }); @@ -9101,6 +9173,8 @@ app.use((req, _res, next) => { if (req.path.startsWith('/api/auth/') || req.path === '/login.html') return next(); // PPT skin thumbnails are UI decoration — allow without auth if (req.path.startsWith('/api/ppt/skins/') || req.path === '/api/ppt/skins') return next(); + // 카카오 오픈빌더 웹훅 — 카카오 서버에서 오는 요청이라 인증 없음 + if (req.path === '/api/kakao/webhook') return next(); const token = extractAuthToken(req); const session = token ? activeSessions.get(token) : undefined; @@ -9111,9 +9185,14 @@ app.use((req, _res, next) => { return next(); } + // HLS 프록시: 브라우저 hls.js가 헤더 추가 불가 → 도메인 화이트리스트로 보안 유지 + if (req.path === '/api/traffic/hls-proxy') return next(); // Static assets (CSS, JS, images, fonts) are public — but token check runs first above // so that fetch() calls with Bearer token still get user context set correctly. - if (/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(req.path)) return next(); + if (/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|wasm)$/i.test(req.path)) return next(); + // Wavacity / AudioMass self-hosted audio editors + if (req.path.startsWith('/wavacity/') || req.path === '/wavacity') return next(); + if (req.path.startsWith('/audiomass/') || req.path === '/audiomass') return next(); // Code serving: allow without auth (falls back to default workspace) if (req.path.startsWith('/code-serve/')) return next(); // Internal-only endpoints: allow from localhost without auth @@ -9190,6 +9269,100 @@ app.delete('/api/users/:username', (req, res) => { for (const [token, info] of activeSessions) { if (info.username === username) activeSessions.delete(token); } + saveActiveSessions(); + res.json({ success: true }); +}); + +// ─── Per-user App Session IDs ───────────────────────────────────────────────── +// Stored at /.smallclaw/app-sessions.json +// Keys: iv, lw, dt, music, ac, mn, wt (per web-app) + +function appSessionsPath(username: string): string { + return path.join(getUserWorkspace(username), '.smallclaw', 'app-sessions.json'); +} + +app.get('/api/user/app-sessions', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const p = appSessionsPath(session.username); + if (!fs.existsSync(p)) return res.json({}); + try { res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); } catch { res.json({}); } +}); + +app.put('/api/user/app-sessions', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const { appKey, sessionId } = req.body || {}; + if (!appKey || !sessionId || typeof appKey !== 'string' || typeof sessionId !== 'string') + return res.status(400).json({ error: 'appKey and sessionId required' }); + const p = appSessionsPath(session.username); + fs.mkdirSync(path.dirname(p), { recursive: true }); + let data: Record = {}; + try { if (fs.existsSync(p)) data = JSON.parse(fs.readFileSync(p, 'utf-8')); } catch {} + data[appKey] = sessionId; + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8'); + fs.renameSync(tmp, p); + res.json({ success: true }); +}); + +// ─── Per-user Detective Cases ───────────────────────────────────────────────── +// Stored at /.smallclaw/detective-cases.json + +function detectiveCasesPath(username: string): string { + return path.join(getUserWorkspace(username), '.smallclaw', 'detective-cases.json'); +} + +app.get('/api/detective/cases', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const p = detectiveCasesPath(session.username); + if (!fs.existsSync(p)) return res.json({ cases: [] }); + try { res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); } catch { res.json({ cases: [] }); } +}); + +app.put('/api/detective/cases', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const { cases } = req.body || {}; + if (!Array.isArray(cases)) return res.status(400).json({ error: 'cases array required' }); + const p = detectiveCasesPath(session.username); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify({ cases }, null, 2), 'utf-8'); + fs.renameSync(tmp, p); + res.json({ success: true }); +}); + +// ─── Per-user Investor Watchlist ───────────────────────────────────────────── +// Stored at /.smallclaw/investor-watchlist.json + +function investorWatchlistPath(username: string): string { + return path.join(getUserWorkspace(username), '.smallclaw', 'investor-watchlist.json'); +} + +app.get('/api/investor/watchlist', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const p = investorWatchlistPath(session.username); + if (!fs.existsSync(p)) return res.json({ kr: null, tv: null }); + try { + res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); + } catch { + res.json({ kr: null, tv: null }); + } +}); + +app.put('/api/investor/watchlist', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const { kr, tv } = req.body || {}; + if (!Array.isArray(kr) || !Array.isArray(tv)) return res.status(400).json({ error: 'kr and tv arrays required' }); + const p = investorWatchlistPath(session.username); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify({ kr, tv }, null, 2), 'utf-8'); + fs.renameSync(tmp, p); res.json({ success: true }); }); @@ -9353,6 +9526,23 @@ app.use('/vendor', express.static(path.join(webUiPath, 'vendor'), { if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); } })); + +// AudioMass — pure JS audio editor, no COOP/COEP needed +app.use('/audiomass', express.static(path.join(webUiPath, 'audiomass'), { + setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } +})); + +// Wavacity — requires COOP/COEP for SharedArrayBuffer +app.use('/wavacity', express.static(path.join(webUiPath, 'wavacity'), { + setHeaders: (res, filePath) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + res.setHeader('Cache-Control', 'no-cache'); + if (filePath.endsWith('.wasm')) res.setHeader('Content-Type', 'application/wasm'); + if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); + } +})); + app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } })); // Serve code directory files for HTML preview (window.open) @@ -11160,11 +11350,13 @@ app.post('/api/channels/config', async (req, res) => { persistToken('channels.telegram.botToken', incoming.telegram?.botToken); persistToken('channels.discord.botToken', incoming.discord?.botToken); persistToken('channels.whatsapp.accessToken', incoming.whatsapp?.accessToken); + persistToken('channels.kakao.appKey', incoming.kakao?.appKey); // Strip actual token values before merging into config — tokens live in vault only. const inTg = { ...(incoming.telegram || {}), botToken: '' }; const inDc = { ...(incoming.discord || {}), botToken: '' }; const inWa = { ...(incoming.whatsapp || {}), accessToken: '' }; + const inKk = { ...(incoming.kakao || {}), appKey: '' }; const rawChannels = current.channels || {}; const mergedTelegram = normalizeTelegramConfig({ ...(rawChannels.telegram || {}), ...inTg }); @@ -11176,6 +11368,7 @@ app.post('/api/channels/config', async (req, res) => { telegram: { ...mergedTelegram, botToken: '' }, // never persist resolved token to disk discord: { ...mergedDiscord, botToken: '' }, whatsapp: { ...mergedWhatsApp, accessToken: '' }, + kakao: { ...(rawChannels.kakao || {}), ...inKk }, }; // Keep legacy top-level telegram key in sync for backward compatibility. @@ -11186,6 +11379,9 @@ app.post('/api/channels/config', async (req, res) => { // Hot-reload telegram with the live resolved token (from vault) telegramChannel.updateConfig(mergedTelegram); + // Hot-reload kakao channel enabled state + const savedKakao = resolveChannelsConfig().kakao; + kakaoChannel.updateConfig({ enabled: savedKakao.channelEnabled, userMap: savedKakao.userMap }); res.json({ success: true, @@ -11239,6 +11435,23 @@ app.post('/api/channels/send-test/:channel', async (req, res) => { return; } + if (channel === 'kakao') { + const conf = resolveChannelsConfig().kakao; + if (!conf.appKey || !conf.refreshToken) { + res.json({ success: false, error: 'API 키 또는 OAuth 토큰이 없습니다. 카카오 로그인을 먼저 완료하세요.' }); + return; + } + try { + const notifier = new KakaoNotify({ appKey: conf.appKey, refreshToken: conf.refreshToken }); + await notifier.send('🦞 SmallClaw 테스트 메시지 — 카카오 알림 연결 완료!'); + kakaoNotify.updateTokens(conf.refreshToken); + res.json({ success: true }); + } catch (err: any) { + res.json({ success: false, error: String(err?.message || err) }); + } + return; + } + if (channel === 'discord') { const dc = normalizeDiscordConfig({ ...channels.discord, ...(req.body || {}) }); const text = String(req.body?.text || '🦞 SmallClaw test message - Discord is connected!'); @@ -11368,6 +11581,87 @@ app.post('/api/telegram/send-test', async (req, res) => { } }); +// ─── 카카오 라우트 ──────────────────────────────────────────────────────────── + +// 챗봇 웹훅 — 오픈빌더 스킬 서버 URL로 등록 +app.post('/api/kakao/webhook', async (req, res) => { + const utterance = req.body?.userRequest?.utterance || '(검증)'; + const userId = req.body?.userRequest?.user?.id || '-'; + console.log(`[KakaoChannel] 웹훅 수신 userId=${userId} utterance="${utterance}"`); + if (!kakaoChannel.enabled) { res.status(404).json({ error: 'kakao channel not enabled' }); return; } + try { + const response = await kakaoChannel.handleWebhook(req.body); + res.json(response); + } catch (err: any) { + console.error('[KakaoChannel] webhook error:', err); + res.json({ version: '2.0', template: { outputs: [{ simpleText: { text: '오류가 발생했습니다.' } }] } }); + } +}); + +// OAuth 시작 — 브라우저에서 열면 카카오 로그인 페이지로 이동 +app.get('/api/kakao/auth/start', (req, res) => { + const conf = resolveChannelsConfig().kakao; + if (!conf.appKey) { res.status(400).send('kakao appKey가 설정되지 않았습니다. vault channels.kakao.appKey 를 먼저 설정하세요.'); return; } + const redirectUri = `${req.protocol}://${req.get('host')}/api/kakao/auth/callback`; + const url = `https://kauth.kakao.com/oauth/authorize?client_id=${encodeURIComponent(conf.appKey)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=talk_message`; + res.redirect(url); +}); + +// OAuth 콜백 — 카카오가 인가코드를 이 URL로 전송 +app.get('/api/kakao/auth/callback', async (req, res) => { + const code = String(req.query.code || ''); + if (!code) { res.status(400).send('인가코드가 없습니다.'); return; } + const conf = resolveChannelsConfig().kakao; + const redirectUri = `${req.protocol}://${req.get('host')}/api/kakao/auth/callback`; + try { + const tokenResp = await fetch('https://kauth.kakao.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: conf.appKey, + redirect_uri: redirectUri, + code, + }), + }); + if (!tokenResp.ok) throw new Error(`token exchange failed: ${tokenResp.status}`); + const data = await tokenResp.json() as { access_token: string; refresh_token: string }; + // vault에 리프레시 토큰 저장 + 실행 중인 인스턴스에 즉시 반영 + getVault(CONFIG_DIR_PATH).set('channels.kakao.refreshToken', data.refresh_token, 'kakao:token'); + kakaoNotify.updateTokens(data.refresh_token, data.access_token); + console.log('[KakaoNotify] OAuth 완료 — 리프레시 토큰 vault 저장됨.'); + res.send('

✅ 카카오 연동 완료!

리프레시 토큰이 vault에 저장됐습니다. 이 창을 닫아도 됩니다.

'); + } catch (err: any) { + res.status(500).send(`OAuth 오류: ${err.message}`); + } +}); + +// 알림 테스트 — vault에서 최신 토큰으로 fresh 인스턴스 사용 +app.post('/api/kakao/test', async (req, res) => { + try { + const conf = resolveChannelsConfig().kakao; + if (!conf.appKey || !conf.refreshToken) { + res.json({ success: false, error: 'API 키 또는 OAuth 토큰이 없습니다. 카카오 로그인을 먼저 완료하세요.' }); + return; + } + const notifier = new KakaoNotify({ appKey: conf.appKey, refreshToken: conf.refreshToken }); + await notifier.send('🦞 SmallClaw 테스트 메시지 — 카카오 알림 연결 완료!'); + kakaoNotify.updateTokens(conf.refreshToken); + res.json({ success: true }); + } catch (err: any) { + res.json({ success: false, error: String(err?.message || err) }); + } +}); + +// 연동 상태 — 항상 config에서 직접 읽어 최신값 반환 +app.get('/api/kakao/status', (_req, res) => { + const conf = resolveChannelsConfig().kakao; + res.json({ + notify: { enabled: !!(conf.appKey && conf.refreshToken), hasAppKey: !!conf.appKey, hasRefreshToken: !!conf.refreshToken }, + channel: { enabled: conf.channelEnabled }, + }); +}); + type AgentToolProfile = 'minimal' | 'coding' | 'web' | 'full'; function sanitizeAgentId(value: any): string { @@ -12973,6 +13267,74 @@ app.get('/api/ppt/skins/{*skinPath}', (req, res) => { // POST /api/settings/voice — save voice config // GET /api/settings/session — return session config // POST /api/settings/session — save session config + +// ─── Traffic CCTV (ITS 국가교통정보) ───────────────────────────────────────── +app.get('/api/traffic/cctv', requireGatewayAuth, async (req, res) => { + const cfg = getConfig().getConfig() as any; + const apiKey = cfg?.traffic?.its_api_key; + if (!apiKey) { res.status(503).json({ error: 'Traffic API key not configured' }); return; } + + const { minX = '124.0', maxX = '132.0', minY = '33.0', maxY = '39.0', cctvType = '1' } = req.query as Record; + const url = `https://openapi.its.go.kr:9443/cctvInfo?apiKey=${encodeURIComponent(apiKey)}&type=its&cctvType=${cctvType}&minX=${minX}&maxX=${maxX}&minY=${minY}&maxY=${maxY}&getType=json`; + + try { + const r = await fetch(url); + if (!r.ok) { res.status(502).json({ error: `ITS API error ${r.status}` }); return; } + const data = await r.json() as any; + const items: any[] = data?.response?.data || []; + res.json({ count: items.length, items }); + } catch (e: any) { + res.status(502).json({ error: String(e?.message || e) }); + } +}); + +// HLS 프록시 — Mixed Content 우회 (HTTP CCTV → HTTPS 서버 경유) +// auth 제외: hls.js 세그먼트 요청은 헤더 커스텀 불가, 도메인 화이트리스트로 충분 +app.get('/api/traffic/hls-proxy', async (req, res) => { + // req.query.url은 Express가 이미 디코딩 — decodeURIComponent 이중디코딩 금지 + const targetUrl = String(req.query.url || ''); + if (!targetUrl) { res.status(400).end(); return; } + // 허용 도메인: ITS CCTV 스트리밍 서버 (포트 포함) + if (!/^https?:\/\/cctvsec\.ktict\.co\.kr(:\d+)?\//i.test(targetUrl)) { + res.status(403).end(); return; + } + try { + const r = await fetch(targetUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': '*/*', + 'Referer': 'https://its.go.kr/', + } + }); + if (!r.ok) { res.status(r.status).end(); return; } + const ct = r.headers.get('content-type') || ''; + // r.url = 리다이렉트 후 최종 URL — 상대경로 해석의 올바른 base + const finalUrl = r.url || targetUrl; + if (ct.includes('mpegurl') || finalUrl.includes('.m3u8') || targetUrl.includes('.m3u8')) { + // m3u8 내부 URL을 프록시 경로로 재작성 + const text = await r.text(); + const base = new URL(finalUrl); + const rewritten = text.split('\n').map(line => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return line; + const abs = trimmed.startsWith('http') ? trimmed : new URL(trimmed, base).toString(); + return `/api/traffic/hls-proxy?url=${encodeURIComponent(abs)}`; + }).join('\n'); + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(rewritten); + } else { + // TS 세그먼트 등 바이너리 통과 + res.setHeader('Content-Type', ct || 'video/MP2T'); + res.setHeader('Cache-Control', 'public, max-age=5'); + const buf = await r.arrayBuffer(); + res.send(Buffer.from(buf)); + } + } catch (e: any) { + res.status(502).end(); + } +}); + registerMCPRoutes(app); // Mounted dynamically so the path is always read fresh from config. diff --git a/web-ui/accountant-app.html b/web-ui/accountant-app.html index 872e3f8..ec887b7 100644 --- a/web-ui/accountant-app.html +++ b/web-ui/accountant-app.html @@ -108,7 +108,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .ac-files::-webkit-scrollbar-thumb,.ac-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;} /* ── Mobile ── */ -@media(max-width:640px){ +@media(max-width:1024px){ .ac-resize{display:none;} .ac-sb{ position:fixed;top:0;left:0;bottom:0;z-index:500; @@ -131,7 +131,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis #mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:400;background:var(--panel);border-top:1px solid var(--line);height:52px;} .ac-textarea,input,textarea{font-size:16px !important;} } -@media(min-width:641px){ +@media(min-width:1025px){ #mob-tabbar{display:none !important;} #mob-backdrop{display:none !important;} } @@ -236,7 +236,7 @@ function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Author async function checkAuth(){ if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;} - try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;} + try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;} catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;} } @@ -295,10 +295,39 @@ async function loadModels(){ function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}} // ── Skill ───────────────────────────────────────────────────────────────────── -const SID_KEY='ac_app_session'; +const APP_KEY='ac'; let skillContext=''; -function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='ac_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'ac_main';}} +let _sessionId=null; +function getSessionId(){return _sessionId||'ac_main';} +async function initSessionId(){ + try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{} + _sessionId='ac_'+Math.random().toString(36).slice(2,12); + try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} +} + +async function loadHistory(){ + const sid=getSessionId(); + if(!sid||sid.endsWith('_main'))return; + try{ + const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()}); + if(!r.ok)return; + const d=await r.json(); + const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant'); + if(!msgs.length)return; + const recent=msgs.slice(-30); + const el=document.getElementById('chat-msgs'); + const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep); + for(const m of recent){ + if(m.role==='user')addMsg('user',escHtml(m.content||'')); + else addMsg('assistant',renderMd(m.content||'')); + } + const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2); + el.scrollTop=el.scrollHeight; + }catch{} +} + + const SKILL_CONTENT="---\nname: Accountant\ndescription: \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34\ubd84\uc11d\u00b7\uacb0\uc0b0\u00b7\uc6d0\uac00\uad00\ub9ac\u00b7\ud22c\uc790\ud3c9\uac00\ub97c \uc544\uc6b0\ub974\ub294 \uacf5\uc778\ud68c\uacc4\uc0ac \uc5b4\uc2dc\uc2a4\ud134\ud2b8. K-IFRS, \uc138\ubc95, \ubc95\uc778\u00b7\uac1c\uc778 \uc138\ubb34 \uc804\ubc18 \uc9c0\uc6d0.\nemoji: \"\ud83d\udcca\"\nversion: 1.0.0\ntriggers: [\uc138\uae08, \uc138\ubb34, \ud68c\uacc4, \uc7ac\ubb34, \uacb0\uc0b0, \ubd80\uac00\uc138, \uc18c\ub4dd\uc138, \ubc95\uc778\uc138, \ube44\uc6a9, \uc190\uc775, \uc790\uc0b0, \ubd80\ucc44, \uac10\uac00\uc0c1\uac01, \ubd84\uac1c, \uc7a5\ubd80, \uc6d0\uac00, \uc808\uc138, \ud658\uae09, \uc2e0\uace0, \ub0a9\ubd80, \uacf5\uc81c, \ub9e4\ucd9c, \uc601\uc218\uc99d, \uc138\uae08\uacc4\uc0b0\uc11c]\n---\n\n\ub2f9\uc2e0\uc740 \uacf5\uc778\ud68c\uacc4\uc0ac(CPA) \uc218\uc900\uc758 \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34 \uc804\ubb38 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uc815\ud655\ud558\uace0 \uc2e4\uc6a9\uc801\uc778 \ub2f5\ubcc0\uc744 \uc81c\uacf5\ud558\uba70, \ubcf5\uc7a1\ud55c \uac1c\ub150\uc740 \uc27d\uac8c \ud480\uc5b4 \uc124\uba85\ud558\uc138\uc694. \ud68c\uacc4\u00b7\uc138\ubb34 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4\ub97c \uad04\ud638\uc5d0 \ubcd1\uae30\ud558\uc138\uc694. (\uc608: \uac10\uac00\uc0c1\uac01(depreciation))\n\n---\n\n### \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ub3c4\uad6c\n\n| \ub3c4\uad6c | \ud65c\uc6a9 \uc0c1\ud669 |\n|---|---|\n| `web_search` | \uc138\ubc95 \uac1c\uc815\uc0ac\ud56d\u00b7\uad6d\uc138\uccad \uace0\uc2dc\u00b7\ud68c\uacc4\uae30\uc900 \ubcc0\uacbd\u00b7\ud310\ub840\u00b7\uc138\uc728\ud45c \ucd5c\uc2e0 \uc815\ubcf4 \uac80\uc0c9 |\n| `pdf_read` | \uc7ac\ubb34\uc81c\ud45c\u00b7\uc138\uae08\uacc4\uc0b0\uc11c\u00b7\uacc4\uc57d\uc11c\u00b7\uac10\uc0ac\ubcf4\uace0\uc11c PDF \ubd84\uc11d |\n| `excel_read` | Excel \uc7ac\ubb34\uc81c\ud45c\u00b7\uc6d0\uc7a5\u00b7\uc138\uae08\uacc4\uc0b0\uc11c \ub370\uc774\ud130 \uc77d\uae30 (\uc2dc\ud2b8 \uc9c0\uc815, \ubc94\uc704 \uc9c0\uc815 \uac00\ub2a5) |\n| `excel_write` | \uc138\uc561 \uacc4\uc0b0 \uacb0\uacfc\u00b7\uc7ac\ubb34\ubd84\uc11d \ubcf4\uace0\uc11c\ub97c Excel \ud30c\uc77c\ub85c \uc800\uc7a5 |\n| `python_eval` | \uc138\uc561 \uacc4\uc0b0, \uc7ac\ubb34\ube44\uc728 \uc0b0\ucd9c, \uc190\uc775\ubd84\uc11d, \ud604\uae08\ud750\ub984 \ucd94\uc815, \ubcf5\ub9ac \uacc4\uc0b0 |\n\n### \uc6b0\uc120 \ucc38\uc870 \uc0ac\uc774\ud2b8\n\n`web_search` \ud638\ucd9c \uc2dc \uc544\ub798 **site: \uc5f0\uc0b0\uc790\ub97c query\uc5d0 \ud3ec\ud568**\ud574 \uacf5\uc2e0\ub825 \uc788\ub294 \uc138\ubb34\u00b7\ud68c\uacc4 \uc815\ubcf4\ub97c \uc6b0\uc120 \uac80\uc0c9\ud558\uc138\uc694.\n\n| \uc8fc\uc81c | site: \uc5f0\uc0b0\uc790 | \uc124\uba85 |\n|---|---|---|\n| \uc138\ubc95 \uc6d0\ubb38\u00b7\uc720\uad8c\ud574\uc11d | `site:taxlaw.nts.go.kr` | \uad6d\uc138\ubc95\ub839\uc815\ubcf4\uc2dc\uc2a4\ud15c (\ubc95\ub839\u00b7\uc608\uaddc\u00b7\ud310\ub840) |\n| \uad6d\uc138\uccad \uacf5\uc2dd \uc548\ub0b4 | `site:nts.go.kr` | \uc138\uc728\ud45c\u00b7\uc2e0\uace0 \ubc29\ubc95\u00b7\uac01\uc885 \uace0\uc2dc |\n| \uae30\uc5c5 \uacf5\uc2dc (\uc7ac\ubb34\uc81c\ud45c) | `site:dart.fss.or.kr` | \uae08\uc735\uac10\ub3c5\uc6d0 DART \uc804\uc790\uacf5\uc2dc |\n| K-IFRS \ud68c\uacc4\uae30\uc900 | `site:kasb.or.kr` | \ud55c\uad6d\ud68c\uacc4\uae30\uc900\uc6d0 \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uae30\ud68d\uc7ac\uc815\ubd80 \uc815\ucc45 | `site:moef.go.kr` | \uc138\uc81c \uac1c\ud3b8\uc548\u00b7\uc608\uc0b0\u00b7\uc7ac\uc815 \uc815\ucc45 |\n| \uad6d\uc81c IFRS \uae30\uc900 | `site:ifrs.org` | IFRS Foundation \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uad00\uc138\u00b7\ubb34\uc5ed | `site:customs.go.kr` | \uad00\uc138\uccad (\uc218\ucd9c\uc785 \uc138\uc728\u00b7\ud488\ubaa9\ubd84\ub958) |\n\n\uc608\uc2dc: 2024\ub144 \ubc95\uc778\uc138\uc728 \ubcc0\uacbd \u2192 `web_search(\"site:nts.go.kr \ubc95\uc778\uc138\uc728 2024 \uac1c\uc815\")`\n\n---\n\n### \uc138\ubb34 \uc0c1\ub2f4\n\n#### \uac1c\uc778 \uc138\uae08\n- **\uc885\ud569\uc18c\ub4dd\uc138**: \uc18c\ub4dd \uc885\ub958\ubcc4 \ubd84\ub958(\uadfc\ub85c\u00b7\uc0ac\uc5c5\u00b7\uc774\uc790\u00b7\ubc30\ub2f9\u00b7\uc5f0\uae08\u00b7\uae30\ud0c0), \uacf5\uc81c \ud56d\ubaa9 \ucd5c\uc801\ud654\n- **\uc591\ub3c4\uc18c\ub4dd\uc138**: \ubd80\ub3d9\uc0b0\u00b7\uc8fc\uc2dd\u00b7\ube44\uc0c1\uc7a5\uc8fc\uc2dd \uc591\ub3c4\ucc28\uc775 \uacc4\uc0b0, \ube44\uacfc\uc138\u00b7\uac10\uba74 \uc694\uac74\n- **\uc0c1\uc18d\u00b7\uc99d\uc5ec\uc138**: \uacfc\uc138\uac00\uc561 \uacc4\uc0b0, \uacf5\uc81c \ud55c\ub3c4, \uc2e0\uace0 \uae30\ud55c(3\uac1c\uc6d4/6\uac1c\uc6d4)\n- **\ubd80\uac00\uac00\uce58\uc138**: \uc77c\ubc18\u00b7\uac04\uc774\uacfc\uc138\uc790 \uad6c\ubd84, \ub9e4\uc785\uc138\uc561 \uacf5\uc81c, \uc2e0\uace0 \uc8fc\uae30\n- **\uae08\uc735\uc18c\ub4dd \uc885\ud569\uacfc\uc138**: 2\ucc9c\ub9cc \uc6d0 \ucd08\uacfc \uc2dc \ud569\uc0b0 \uae30\uc900, \uc6d0\ucc9c\uc9d5\uc218 \uc138\uc728\n\n#### \ubc95\uc778 \uc138\uae08\n- **\ubc95\uc778\uc138**: \uacfc\uc138\ud45c\uc900 \uacc4\uc0b0, \uc138\uc728 \uad6c\uac04(2\uc5b5 \uc774\ud558 9%\u00b72\uc5b5~200\uc5b5 19%\u00b7200\uc5b5~3\ucc9c\uc5b5 21%\u00b7\ucd08\uacfc 24%), \uc138\uc561\uacf5\uc81c\u00b7\uac10\uba74\n- **\uc6d0\ucc9c\uc9d5\uc218**: \uc778\uac74\ube44\u00b7\uc6a9\uc5ed\ube44\u00b7\uc774\uc790\u00b7\ubc30\ub2f9 \uc6d0\ucc9c\uc138 \uc138\uc728\ud45c\n- **\uc774\uc804\uac00\uaca9**: \ud2b9\uc218\uad00\uacc4\uc790 \uac70\ub798 \uc815\uc0c1\uac00\uaca9 \uc0b0\uc815 \ubc29\ubc95\n- **\uc138\ubb34\uc870\uc0ac**: \ub300\ube44 \uc11c\ub958, \uc18c\uba85 \ubc29\ubc95, \ubd88\ubcf5 \uc808\ucc28(\uc774\uc758\uc2e0\uccad \u2192 \uc2ec\ud310\uccad\uad6c \u2192 \ud589\uc815\uc18c\uc1a1)\n\n\uc138\uae08 \uacc4\uc0b0 \uc2dc `python_eval`\ub85c \uc815\ud655\ud55c \uc0b0\uc2dd\uc744 \uc81c\uc2dc\ud558\uc138\uc694:\n```python\n# \uc608: \uc591\ub3c4\uc18c\ub4dd\uc138 \uacc4\uc0b0\n\uc591\ub3c4\uac00\uc561 = ...\n\ucde8\ub4dd\uac00\uc561 = ...\n\ud544\uc694\uacbd\ube44 = ...\n\uc591\ub3c4\ucc28\uc775 = \uc591\ub3c4\uac00\uc561 - \ucde8\ub4dd\uac00\uc561 - \ud544\uc694\uacbd\ube44\n\uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c = ...\n\uacfc\uc138\ud45c\uc900 = \uc591\ub3c4\ucc28\uc775 - \uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c - \uae30\ubcf8\uacf5\uc81c(2_500_000)\n\uc138\uc728 = ...\n\uc0b0\ucd9c\uc138\uc561 = \uacfc\uc138\ud45c\uc900 * \uc138\uc728\n```\n\n---\n\n### \uc7ac\ubb34\uc81c\ud45c \ubd84\uc11d\n\n\uc7ac\ubb34\uc81c\ud45c(\uc190\uc775\uacc4\uc0b0\uc11c\u00b7\uc7ac\ubb34\uc0c1\ud0dc\ud45c\u00b7\ud604\uae08\ud750\ub984\ud45c)\uac00 \uc81c\uacf5\ub418\uba74 \ub2e4\uc74c \uc21c\uc11c\ub85c \ubd84\uc11d\ud558\uc138\uc694:\n\n1. **\uc218\uc775\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\ucd1d\uc774\uc775\ub960(Gross Margin) = \ub9e4\ucd9c\ucd1d\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc601\uc5c5\uc774\uc775\ub960(Operating Margin) = \uc601\uc5c5\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc21c\uc774\uc775\ub960(Net Margin) = \ub2f9\uae30\uc21c\uc774\uc775 / \ub9e4\ucd9c\uc561\n - ROE = \ub2f9\uae30\uc21c\uc774\uc775 / \uc790\uae30\uc790\ubcf8, ROA = \ub2f9\uae30\uc21c\uc774\uc775 / \ucd1d\uc790\uc0b0\n\n2. **\uc548\uc815\uc131 \ubd84\uc11d**\n - \uc720\ub3d9\ube44\uc728 = \uc720\ub3d9\uc790\uc0b0 / \uc720\ub3d9\ubd80\ucc44 (200% \uc774\uc0c1 \uc591\ud638)\n - \ubd80\ucc44\ube44\uc728 = \ubd80\ucc44 / \uc790\uae30\uc790\ubcf8 (100% \uc774\ud558 \uc548\uc815)\n - \uc774\uc790\ubcf4\uc0c1\ubc30\uc728 = \uc601\uc5c5\uc774\uc775 / \uc774\uc790\ube44\uc6a9 (3\ubc30 \uc774\uc0c1 \uad8c\uace0)\n\n3. **\uc131\uc7a5\uc131\u00b7\ud6a8\uc728\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\uc131\uc7a5\ub960, \uc7ac\uace0\uc790\uc0b0\ud68c\uc804\uc728, \ub9e4\ucd9c\ucc44\uad8c\ud68c\uc804\uc728\n\n4. **\ud604\uae08\ud750\ub984 \ubd84\uc11d**\n - \uc601\uc5c5/\ud22c\uc790/\uc7ac\ubb34 \ud604\uae08\ud750\ub984 \uad6c\uc870, FCF(\uc789\uc5ec\ud604\uae08\ud750\ub984) \uacc4\uc0b0\n\n5. **\uc885\ud569 \uc758\uacac** \u2014 \uc7ac\ubb34 \uac74\uc804\uc131 \ud3c9\uac00 + \uc8fc\uc758 \ud56d\ubaa9 + \uac1c\uc120 \uc81c\uc548\n\n---\n\n### \ud68c\uacc4\ucc98\ub9ac (\ubd84\uac1c\u00b7\uae30\uc7a5)\n\n\ud68c\uacc4\ucc98\ub9ac \uc9c8\ubb38 \uc2dc:\n- **\ubd84\uac1c \ud615\uc2dd**: `(\ucc28) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561 / (\ub300) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561`\n- **\uc801\uc6a9 \uae30\uc900**: K-IFRS(\uc0c1\uc7a5\u00b7\ub300\ud615) \ub610\ub294 \uc77c\ubc18\uae30\uc5c5\ud68c\uacc4\uae30\uc900(K-GAAP, \uc911\uc18c\uae30\uc5c5)\n- **\uc8fc\uc694 \ud56d\ubaa9**: \uac10\uac00\uc0c1\uac01, \ucda9\ub2f9\ubd80\ucc44, \ub9ac\uc2a4(IFRS 16), \uc218\uc775\uc778\uc2dd(IFRS 15), \uae08\uc735\uc0c1\ud488(IFRS 9)\n\n\uc608\uc2dc:\n```\n\ube44\ud488 \ucde8\ub4dd (\ubd80\uac00\uc138 \ud3ec\ud568)\n(\ucc28) \ube44\ud488 1,000,000\n \ubd80\uac00\uc138\ub300\uae09\uae08 100,000\n(\ub300) \ubcf4\ud1b5\uc608\uae08 1,100,000\n```\n\n---\n\n### \uc6d0\uac00 \uad00\ub9ac / \uad00\ub9ac\ud68c\uacc4\n\n- **\uc6d0\uac00 \ubd84\ub958**: \ubcc0\ub3d9\ube44\u00b7\uace0\uc815\ube44, \uc9c1\uc811\ube44\u00b7\uac04\uc811\ube44, \uc81c\uc870\uc6d0\uac00\u00b7\ud310\uad00\ube44\n- **\uc190\uc775\ubd84\uae30\uc810(BEP)**: BEP = \uace0\uc815\ube44 / (\ub2e8\uc704 \ud310\ub9e4\uac00 - \ub2e8\uc704 \ubcc0\ub3d9\ube44)\n- **CVP \ubd84\uc11d**: \ubaa9\ud45c\uc774\uc775 \ub2ec\uc131 \ud310\ub9e4\ub7c9 \uacc4\uc0b0\n- **\uc608\uc0b0 \ub300\ube44 \uc2e4\uc801(Budget vs. Actual)**: \ucc28\uc774 \uc6d0\uc778 \ubd84\uc11d(\uac00\uaca9\ucc28\uc774\u00b7\uc218\ub7c9\ucc28\uc774)\n- **ABC \uc6d0\uac00\uacc4\uc0b0**: \ud65c\ub3d9 \uae30\ubc18 \uc6d0\uac00 \ubc30\ubd80\n\n---\n\n### \ud22c\uc790\u00b7\uac00\uce58\ud3c9\uac00\n\n- **DCF(\ud604\uae08\ud750\ub984\ud560\uc778\ubc95)**: WACC \uc0b0\ucd9c, \uc794\uc5ec\uac00\uce58(Terminal Value) \uacc4\uc0b0\n- **\uba40\ud2f0\ud50c \ubc38\ub958\uc5d0\uc774\uc158**: PER, PBR, EV/EBITDA, PSR\n- **IRR\u00b7NPV**: \ud22c\uc790 \ud0c0\ub2f9\uc131 \ud310\ub2e8\n- **\ubd80\ub3d9\uc0b0 \ud22c\uc790 \uc218\uc775\ub960**: \uc784\ub300\uc218\uc775\ub960, \uc790\ubcf8\ud658\uc6d0\uc728(Cap Rate)\n\n---\n\n### \ucc3d\uc5c5\u00b7\uc0ac\uc5c5 \ud68c\uacc4 \uc2e4\ubb34\n\n- **\uc0ac\uc5c5\uc790 \uc720\ud615 \uc120\ud0dd**: \uac1c\uc778\uc0ac\uc5c5\uc790 vs \ubc95\uc778 \uc124\ub9bd (\uc138\uae08\u00b7\ucc45\uc784\u00b7\ube44\uc6a9 \ube44\uad50)\n- **\uac04\uc774\uacfc\uc138 vs \uc77c\ubc18\uacfc\uc138**: \uc5f0 \ub9e4\ucd9c 8\ucc9c\ub9cc \uc6d0 \uae30\uc900, \uc5c5\uc885\ubcc4 \ubd80\uac00\uc728\n- **4\ub300 \ubcf4\ud5d8**: \uc9c1\uc6d0 \uace0\uc6a9 \uc2dc \uc0ac\uc5c5\uc8fc \ubd80\ub2f4 \ubcf4\ud5d8\ub8cc \uacc4\uc0b0\n- **\uac00\uc0b0\uc138 \uc8fc\uc758**: \ubb34\uc2e0\uace0\u00b7\uacfc\uc18c\uc2e0\uace0\u00b7\ub0a9\ubd80\ubd88\uc131\uc2e4 \uac00\uc0b0\uc138 \uc138\uc728\n- **\uc804\uc790\uc138\uae08\uacc4\uc0b0\uc11c**: \ubc1c\ud589 \uc758\ubb34, \uc9c0\uc5f0\ubc1c\ud589 \uac00\uc0b0\uc138, \uc218\uc815\ubc1c\ud589 \ubc29\ubc95\n\n---\n\n### \uc751\ub2f5 \ud1a4 \uac00\uc774\ub4dc\n\n- **\uc815\ud655\u00b7\uc2e0\ub8b0\uac10**: \uc218\uce58\uc640 \uc138\uc728\uc740 \uc815\ud655\ud558\uac8c, \ubd88\ud655\uc2e4\ud55c \uacbd\uc6b0 \"\ucd5c\uc2e0 \uc138\ubc95 \ud655\uc778 \uad8c\uc7a5\" \uba85\uc2dc\n- **\uc2e4\uc6a9\uc801**: \uc774\ub860\ubcf4\ub2e4 \"\uc2e4\uc81c\ub85c \uc5bc\ub9c8\" \"\uc5b4\ub514\uc5d0 \uc2e0\uace0\" \"\uc5b8\uc81c\uae4c\uc9c0\" \uc704\uc8fc\n- **\ub2e8\uacc4\ubcc4 \uc548\ub0b4**: \ubcf5\uc7a1\ud55c \uc138\ubb34 \uc774\uc288\ub294 \u2460 \u2461 \u2462 \uc21c\uc11c\ub85c \uc815\ub9ac\n- **\uacc4\uc0b0\uc740 \ucf54\ub4dc\ub85c**: \uc138\uc561 \uacc4\uc0b0\uc774 \ud3ec\ud568\ub41c \uacbd\uc6b0 `python_eval`\ub85c \ud22c\uba85\ud558\uac8c \uc0b0\uc2dd \uacf5\uac1c\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uc815\ubcf4 \uc81c\uacf5 \ubaa9\uc801\uc774\uba70 \uacf5\uc2dd \uc138\ubb34\u00b7\ubc95\ub960 \uc790\ubb38\uc744 \ub300\uccb4\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc911\uc694\ud55c \uacb0\uc815 \uc804 \ub2f4\ub2f9 \uc138\ubb34\uc0ac\u00b7\ud68c\uacc4\uc0ac\uc5d0\uac8c \ud655\uc778\ud558\uc138\uc694.\n"; function loadSkill(){ @@ -497,10 +526,11 @@ async function callChat(message){ }finally{btn.disabled=false;btn.textContent='전송';} } -function clearChat(){ +async function clearChat(){ if(!confirm('대화를 지울까요?'))return; document.getElementById('chat-msgs').innerHTML=''; - try{sessionStorage.removeItem(SID_KEY);}catch{} + _sessionId='ac_'+Math.random().toString(36).slice(2,12); + try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} addMsg('system','📊 새 대화가 시작되었습니다.'); } @@ -521,6 +551,8 @@ document.addEventListener('drop',e=>{ // ── Init ────────────────────────────────────────────────────────────────────── (async()=>{ const ok=await checkAuth();if(!ok)return; + await initSessionId(); + await loadHistory(); await Promise.all([loadSkill(),loadModels(),loadExcelFiles()]); addMsg('system','📊 회계 어시스턴트입니다. 세무·회계·재무분석을 도와드립니다.'); document.getElementById('chat-input').focus(); diff --git a/web-ui/app.js b/web-ui/app.js index f808675..5be4eaf 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -3741,6 +3741,15 @@ function setChannelStatus(idPrefix, state, message) { } function readChannelPayload(channel) { + if (channel === 'kakao') { + let userMap = {}; + try { userMap = JSON.parse(document.getElementById('settings-kk-usermap')?.value || '{}'); } catch {} + return { + appKey: (document.getElementById('settings-kk-appkey')?.value || '').trim(), + channelEnabled: !!document.getElementById('settings-kk-channel-enabled')?.checked, + userMap, + }; + } if (channel === 'telegram') { const userIdStr = (document.getElementById('settings-tg-userid')?.value || '').trim(); const allowedUserIds = userIdStr ? userIdStr.split(/[,\s]+/).map(Number).filter(n => !isNaN(n) && n > 0) : []; @@ -3787,23 +3796,26 @@ function onChannelTypeChange() { const ch = getSelectedChannelType(); const formMap = { telegram: 'channel-form-telegram', + kakao: 'channel-form-kakao', discord: 'channel-form-discord', whatsapp: 'channel-form-whatsapp', email: 'channel-form-email', }; const guideMap = { telegram: 'channel-guide-telegram', + kakao: 'channel-guide-kakao', discord: 'channel-guide-discord', whatsapp: 'channel-guide-whatsapp', email: 'channel-guide-email', }; const statusMap = { telegram: 'tg-status-bar', + kakao: 'kk-status-bar', discord: 'dc-status-bar', whatsapp: 'wa-status-bar', email: 'em-status-bar', }; - ['telegram', 'discord', 'whatsapp', 'email'].forEach((k) => { + ['telegram', 'kakao', 'discord', 'whatsapp', 'email'].forEach((k) => { const form = document.getElementById(formMap[k]); const guide = document.getElementById(guideMap[k]); const status = document.getElementById(statusMap[k]); @@ -3812,12 +3824,13 @@ function onChannelTypeChange() { if (status) status.style.display = (k === ch) ? 'flex' : 'none'; }); // Email: hide test/send-test buttons (email has per-account test) + // Kakao: hide "테스트" (연결 확인 없음), 테스트 전송만 노출 const testBtn = document.getElementById('channel-test-btn'); const sendTestBtn = document.getElementById('channel-send-test-btn'); - if (testBtn) testBtn.style.display = (ch === 'email') ? 'none' : ''; + if (testBtn) testBtn.style.display = (ch === 'email' || ch === 'kakao') ? 'none' : ''; if (sendTestBtn) sendTestBtn.style.display = (ch === 'email') ? 'none' : ''; - // Load email accounts when switching to email tab if (ch === 'email') loadEmailAccounts(); + if (ch === 'kakao') loadKakaoStatus(); } async function saveSelectedChannelSettings() { @@ -3883,6 +3896,38 @@ async function loadChannelsStatus() { } catch {} } +async function loadKakaoStatus() { + try { + const data = await api('/api/kakao/status'); + const n = data?.notify || {}; + const c = data?.channel || {}; + if (n.hasAppKey && n.hasRefreshToken) setChannelStatus('kk', 'ok', '알림 연동됨' + (c.enabled ? ' + 챗봇 활성' : '')); + else if (n.hasAppKey) setChannelStatus('kk', 'warn', 'API 키 있음 — OAuth 로그인 필요'); + else setChannelStatus('kk', 'off', '미설정'); + if (n.hasAppKey) document.getElementById('settings-kk-appkey').placeholder = '•••••••• (저장됨)'; + document.getElementById('settings-kk-channel-enabled').checked = !!c.enabled; + } catch {} +} + +async function kakaoOAuthStart() { + const appKey = (document.getElementById('settings-kk-appkey')?.value || '').trim(); + if (appKey) { + // 먼저 appKey 저장 + await saveChannelSettings('kakao'); + } + const status = document.getElementById('kk-oauth-status'); + if (status) status.textContent = '새 창에서 로그인 중...'; + const w = window.open('/api/kakao/auth/start', '_blank', 'width=600,height=700'); + if (!w) { alert('팝업이 차단됐습니다. 팝업을 허용해 주세요.'); return; } + const timer = setInterval(async () => { + if (w.closed) { + clearInterval(timer); + await loadKakaoStatus(); + if (status) status.textContent = ''; + } + }, 1000); +} + async function saveChannelSettings(channel) { const payload = readChannelPayload(channel); try { @@ -8805,7 +8850,7 @@ const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed'; function applyRightPanelCollapsed(collapsed) { document.body.classList.toggle('right-collapsed', !!collapsed); // Only override on desktop — narrow viewports use the mobile single-column layout. - const isDesktop = window.matchMedia('(min-width: 980px)').matches; + const isDesktop = window.matchMedia('(min-width: 1025px)').matches; if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; } if (collapsed) { document.body.style.gridTemplateColumns = '280px 1fr 36px'; diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html index dc3cc26..e07356f 100644 --- a/web-ui/arduino-emulator.html +++ b/web-ui/arduino-emulator.html @@ -309,6 +309,34 @@ .cm-s-default .cm-def { color:var(--yellow) !important; } /* 속도 슬라이더 */ #speed-range { width:70px; height:4px; accent-color:var(--accent); vertical-align:middle; cursor:pointer; } + + /* ── mob-tab (inline since styles.css not loaded) ── */ + .mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;color:var(--muted);font-family:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;cursor:pointer;padding:6px 4px;border-top:2px solid transparent;transition:color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;} + .mob-tab.active{color:var(--accent);border-top-color:var(--accent);} + + /* ── Mobile ── */ + @media(max-width:1024px){ + .header{flex-wrap:wrap;padding:7px 10px;gap:5px;} + .header h1{font-size:13px;} + .toolbar{gap:4px;} + .board-panel{ + position:fixed;top:0;left:0;bottom:0;z-index:500; + width:84vw;max-width:300px; + transform:translateX(-100%);transition:transform .25s ease; + box-shadow:4px 0 24px rgba(0,0,0,.5); + resize:none; + } + .board-panel.mob-open{transform:translateX(0);} + #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;} + #mob-backdrop.show{display:block;} + #mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid var(--bdr);height:52px;} + .main{padding-bottom:52px;} + input,select{font-size:16px !important;} + } + @media(min-width:1025px){ + #mob-backdrop{display:none !important;} + #mob-tabbar{display:none !important;} + } @@ -316,6 +344,12 @@ +
+ +

🔌 Arduino Emulator

sketch.ino @@ -7551,6 +7585,25 @@ document.addEventListener('keydown', e => { compileAndRun(); } }); + +// ── Mobile ─────────────────────────────────────────────────────────────────── +function mobCloseBoard(){ + document.querySelector('.board-panel').classList.remove('mob-open'); + document.getElementById('mob-backdrop').classList.remove('show'); + document.getElementById('mobtab-board').classList.remove('active'); + document.getElementById('mobtab-serial').classList.add('active'); +} +function mobToggleBoard(){ + if(window.innerWidth>640)return; + const open=document.querySelector('.board-panel').classList.contains('mob-open'); + if(open){mobCloseBoard();} + else{ + document.querySelector('.board-panel').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-board').classList.add('active'); + document.getElementById('mobtab-serial').classList.remove('active'); + } +} diff --git a/web-ui/code.html b/web-ui/code.html index 96d2b42..95b5588 100644 --- a/web-ui/code.html +++ b/web-ui/code.html @@ -173,10 +173,57 @@ /* Override the main-app inline display:none / flex on the shared sidebar markup */ #sidebar-code { display: flex !important; } #code-view { flex: 1; min-width: 0; } + + /* ── Mobile ── */ + @media(max-width:1024px){ + .code-page-header{flex-wrap:wrap;padding:6px 10px;gap:5px;} + .code-page-header h1{font-size:13px;} + .code-page-header > div:last-child{display:none;} + /* 좌측 파일 패널 → 왼쪽 오버레이 */ + .code-sidebar-wrap{ + position:fixed;top:0;left:0;bottom:0;z-index:500; + width:82vw;max-width:300px; + transform:translateX(-100%);transition:transform .25s ease; + box-shadow:4px 0 24px rgba(0,0,0,.4); + } + .code-sidebar-wrap.mob-open{transform:translateX(0);} + /* 우측 AI 패널 → 오른쪽 오버레이 */ + #code-ai-panel{ + position:fixed !important;top:0;right:0;bottom:0;z-index:500; + width:92vw;max-width:400px; + transform:translateX(100%);transition:transform .25s ease; + box-shadow:-4px 0 28px rgba(0,0,0,.5); + grid-area:unset !important; + } + #code-ai-panel.mob-open{transform:translateX(0);} + /* 에디터뷰 grid를 에디터 단독으로 */ + #code-view.active{ + grid-template-columns:1fr !important; + grid-template-rows:1fr auto !important; + grid-template-areas:"editor" "term" !important; + } + .code-vresize{display:none;} + #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;} + #mob-backdrop.show{display:block;} + #mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid var(--line);height:52px;} + .code-layout{padding-bottom:52px;} + input,select,textarea{font-size:16px !important;} + } + @media(min-width:1025px){ + #mob-backdrop{display:none !important;} + #mob-tabbar{display:none !important;} + } +
+ +
💻

코드 에디터

@@ -591,6 +638,34 @@
+
diff --git a/web-ui/dental-agent.html b/web-ui/dental-agent.html index f82f1b9..ab5c283 100644 --- a/web-ui/dental-agent.html +++ b/web-ui/dental-agent.html @@ -632,7 +632,7 @@ button.danger:hover { background: #fecaca; } .theme-dot.active { border-color: var(--text); } /* ── Mobile ── */ -@media(max-width:640px){ +@media(max-width:1024px){ .header{flex-wrap:wrap;padding:6px 10px;gap:6px;} .header h1{font-size:13px;} .theme-picker{display:none;} @@ -669,7 +669,7 @@ button.danger:hover { background: #fecaca; } /* iOS zoom */ input,textarea,select{font-size:16px !important;} } -@media(min-width:641px){ +@media(min-width:1025px){ #mob-backdrop{display:none !important;} #mob-tabbar{display:none !important;} } diff --git a/web-ui/detective-app.html b/web-ui/detective-app.html new file mode 100644 index 0000000..0019c4f --- /dev/null +++ b/web-ui/detective-app.html @@ -0,0 +1,1180 @@ + + + + + +🔍 탐정 어시스턴트 + + + + + + + +
+ ← 홈 +

🔍 탐정 어시스턴트

+ 스킬 로딩 중... +
+ +
+ +
+ + + +
+ + + + + +
+
+
🔍
+

좌측에서 사건을 선택하거나
새 사건을 만들어 단서와 메모를
기록하세요.

+
+ +
+ + +
+ + +
+
+ 🔍 탐정 AI + + +
+
+
+ + +
+
+ +
+ + + + diff --git a/web-ui/esp32-flasher.html b/web-ui/esp32-flasher.html index 117a841..b07bd2d 100644 --- a/web-ui/esp32-flasher.html +++ b/web-ui/esp32-flasher.html @@ -81,10 +81,45 @@ @keyframes spin { to{transform:rotate(360deg)} } .chip-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:10px; font-weight:600; background:#1a3a1a; color:var(--green); border:1px solid #2d5a2d; } + + /* ── mob-tab (inline since styles.css not loaded) ── */ + .mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;color:var(--muted);font-family:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;cursor:pointer;padding:6px 4px;border-top:2px solid transparent;transition:color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;} + .mob-tab.active{color:var(--accent);border-top-color:var(--accent);} + + /* ── Mobile ── */ + @media(max-width:1024px){ + .header{flex-wrap:wrap;padding:7px 10px;gap:6px;} + .header h1{font-size:13px;} + .toolbar{gap:4px;} + .toolbar .btn{padding:5px 8px;font-size:11px;} + .left-panel{ + position:fixed;top:0;left:0;bottom:0;z-index:500; + width:84vw;max-width:310px; + transform:translateX(-100%);transition:transform .25s ease; + box-shadow:4px 0 24px rgba(0,0,0,.6); + } + .left-panel.mob-open{transform:translateX(0);} + #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;} + #mob-backdrop.show{display:block;} + #mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid #2d2d4a;height:52px;} + .main{padding-bottom:52px;} + .serial-panel{border:none;} + input,select{font-size:16px !important;} + } + @media(min-width:1025px){ + #mob-backdrop{display:none !important;} + #mob-tabbar{display:none !important;} + } +
+ +

⚡ ESP32 Flasher

sketch.ino @@ -466,6 +501,25 @@ async function boot() { } boot(); + +// ── Mobile ────────────────────────────────────────────────────────────────── +function mobClosePanel(){ + document.querySelector('.left-panel').classList.remove('mob-open'); + document.getElementById('mob-backdrop').classList.remove('show'); + document.getElementById('mobtab-ctrl').classList.remove('active'); + document.getElementById('mobtab-term').classList.add('active'); +} +function mobTogglePanel(){ + if(window.innerWidth>640)return; + const open=document.querySelector('.left-panel').classList.contains('mob-open'); + if(open){mobClosePanel();} + else{ + document.querySelector('.left-panel').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-ctrl').classList.add('active'); + document.getElementById('mobtab-term').classList.remove('active'); + } +} diff --git a/web-ui/index.html b/web-ui/index.html index f130683..70a53bd 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -62,7 +62,9 @@ + +
@@ -1345,6 +1347,7 @@ +
+
OAuth 로그인 (나에게 보내기 알림용)
+
API 키 저장 후 아래 버튼으로 카카오 로그인하면 토큰이 자동 저장됩니다.
+ + +
+ + + +
+ +
@@ -531,6 +605,226 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
+ + + + + + + + +
@@ -824,6 +1118,8 @@ async function checkAuth() { try { const r = await fetch('/api/auth/status', { headers: authH() }); if (!r.ok) { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; } + const d = await r.json(); + if (!d.authenticated) { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; } return true; } catch { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; } } @@ -902,17 +1198,38 @@ function saveModel() { } // ── Session & skill ─────────────────────────────────────────────────────────── -const SID_KEY = 'music_app_session'; +const APP_KEY = 'music'; let skillContext = ''; let abortCtrl = null; const _renderedMidiIds = new Set(); -function getSessionId() { - try { - let sid = sessionStorage.getItem(SID_KEY); - if (!sid) { sid = 'music_' + Math.random().toString(36).slice(2,12); sessionStorage.setItem(SID_KEY, sid); } - return sid; - } catch { return 'music_main'; } +let _sessionId = null; +function getSessionId() { return _sessionId || 'music_main'; } +async function initSessionId() { + try { const r = await fetch('/api/user/app-sessions', {headers: authH()}); if (r.ok) { const d = await r.json(); if (d[APP_KEY]) { _sessionId = d[APP_KEY]; return; } } } catch {} + _sessionId = 'music_' + Math.random().toString(36).slice(2,12); + try { await fetch('/api/user/app-sessions', {method:'PUT', headers: authH({'Content-Type':'application/json'}), body: JSON.stringify({appKey: APP_KEY, sessionId: _sessionId})}); } catch {} +} + +async function loadHistory(){ + const sid=getSessionId(); + if(!sid||sid.endsWith('_main'))return; + try{ + const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()}); + if(!r.ok)return; + const d=await r.json(); + const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant'); + if(!msgs.length)return; + const recent=msgs.slice(-30); + const el=document.getElementById('chat-msgs'); + const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep); + for(const m of recent){ + if(m.role==='user')addChatMsg('user',escHtml(m.content||'')); + else addChatMsg('assistant',renderMd(m.content||'')); + } + const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2); + el.scrollTop=el.scrollHeight; + }catch{} } const SKILL_CONTENT="---\nname: Musician\ndescription: 작곡·편곡·이론 분석·연주 코칭·음악사 해설을 아우르는 범장르 음악 어시스턴트. 클래식부터 재즈·팝·프로듀싱까지.\nemoji: \"🎵\"\nversion: 1.0.0\ntriggers: [음악, 악기, 작곡, 편곡, 코드, 멜로디, 화음, 리듬, 박자, 조성, 악보, MIDI, 음계, 화성, 반주, 연주, 피아노, 기타, 바이올린, 드럼, 노래, 장르, 클래식, 재즈]\n---\n\n## ⚠️ MIDI 직접 생성 — 반드시 읽을 것\n\n이 시스템에는 **Python mido로 MIDI 직접 생성 → 워크스페이스 저장 → 재생/편집 위젯 자동 표시** 파이프라인이 내장되어 있습니다.\n\n작곡 요청 시 **반드시 아래 절차를 따르세요**:\n\n### 1단계: Python으로 MIDI 생성\n\n`python_eval` 도구로 mido 라이브러리를 사용해 MIDI를 직접 생성하고 워크스페이스에 저장합니다.\n\n```python\nimport mido, os\nfrom mido import MidiFile, MidiTrack, Message, MetaMessage\n\nTITLE = \"가을 왈츠\" # 파일명·폴더명으로 사용\nBPM = 120\nTICKS = 480\n\nmid = MidiFile(ticks_per_beat=TICKS)\ntempo = mido.bpm2tempo(BPM)\n\n# 트랙 1: Keyboard (ch 0, program 0=피아노)\nt1 = MidiTrack(); mid.tracks.append(t1)\nt1.append(MetaMessage('track_name', name='Keyboard', time=0))\nt1.append(MetaMessage('set_tempo', tempo=tempo, time=0))\nt1.append(Message('program_change', channel=0, program=0, time=0))\n# ... 음표 추가 (note_on/note_off) ...\n\n# 트랙 2: Guitar (ch 1, program 25=어쿠스틱 기타)\nt2 = MidiTrack(); mid.tracks.append(t2)\nt2.append(MetaMessage('track_name', name='Guitar', time=0))\nt2.append(Message('program_change', channel=1, program=25, time=0))\n# ...\n\n# 트랙 3: Bass (ch 2, program 33=핑거 베이스)\nt3 = MidiTrack(); mid.tracks.append(t3)\nt3.append(MetaMessage('track_name', name='Bass', time=0))\nt3.append(Message('program_change', channel=2, program=33, time=0))\n# ...\n\n# 트랙 4: Drums (ch 9, GM 드럼 채널)\nt4 = MidiTrack(); mid.tracks.append(t4)\nt4.append(MetaMessage('track_name', name='Drums', time=0))\n# note 36=킥, 38=스네어, 42=클로즈햇, 46=오픈햇\n# ...\n\nimport pathlib\nsafe = TITLE.replace(\"/\",\"_\").replace(\"\\\\\",\"_\").replace(\" \",\"_\")\nout_dir = pathlib.Path(os.getcwd()) / \"music\" / safe\nout_dir.mkdir(parents=True, exist_ok=True)\nout_path = out_dir / (safe + \".mid\")\nmid.save(str(out_path))\nprint(str(out_path))\n```\n\n### 2단계: 내부 API로 등록\n\n`shell` 도구로 생성된 MIDI를 등록합니다 (게이트웨이 내부 전용):\n\n```bash\ncurl -s -X POST http://localhost:18789/api/music/midi-from-path \\\n -H \"Content-Type: application/json\" \\\n -d '{\"path\":\"<1단계에서 출력된 경로>\",\"filename\":\"<제목>.mid\"}'\n```\n\n### 3단계: midi-widget 코드블록 출력\n\n등록 API 응답 JSON을 그대로 아래 코드블록에 담아 출력하면, 채팅창에 ▶ 재생 · 🎹 편집 · ⬇ MIDI 위젯이 자동으로 렌더링됩니다:\n\n~~~\n```midi-widget\n{\"id\":\"midi-xxxx\",\"filename\":\"가을 왈츠.mid\",\"midi_url\":\"/api/music/file/midi-xxxx/midi\",\"has_svg\":false,\"page_count\":0,\"svg_url\":null,\"pdf_url\":null,\"saved_to\":\"/path/to/가을 왈츠.mid\"}\n```\n~~~\n\n**절대 금지 문구** (시스템이 처리하므로 언급 불필요):\n- \"직접 생성 불가\" / \"파일을 만들어 드릴 수 없습니다\"\n- \"MIDI로 저장이 어렵습니다\" / \"별도로 저장해 드릴 수 없습니다\"\n- \"LilyBin\", \"온라인 편집기\", \"붙여넣으세요\"\n\n---\n\n## 음악 어시스턴트\n\n당신은 클래식 작곡·재즈 화성·대중음악 프로듀싱·악기 코칭에 두루 능통한 음악 어시스턴트입니다. 열정적이되 정확하게, 이론은 쉬운 말로, 실습은 구체적으로 안내하세요. 음악 용어는 한국어 먼저, 영어를 괄호에 병기하세요. (예: 으뜸음(tonic), 딸림화음(dominant chord))\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `web_search` | 악보·리드시트·음원 자료, 음악가 정보, 장르 레퍼런스 검색 |\n| `pdf_read` | 텍스트 기반 악보 PDF 분석·조성/박자/구조 파악 |\n| `pdf_extract_images` | 이미지(스캔) 기반 악보 PDF에서 페이지 이미지 추출 |\n| `image_read` | 추출된 악보 이미지 시각 분석 (음표·코드·기보 판독) |\n| `python_eval` | 조옮김 계산, 음정·스케일·코드 자동 생성, BPM 변환 |\n| `pubmed_search` | 연습 효율·음악 심리학·운동 손상 예방 연구 조회 |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 악보·음악 정보를 정확하게 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 클래식 악보 (무료) | `site:imslp.org` | IMSLP 페트루치 악보 라이브러리 (퍼블릭 도메인) |\n| 대중음악 악보 | `site:musescore.com` | MuseScore 악보 커뮤니티 |\n| 음악 이론 | `site:musictheory.net` | 기초~고급 이론 강의·연습 |\n| 국악 자료 | `site:gugak.go.kr` | 국립국악원 (국악 악보·음원·이론) |\n| 저작권 정보 | `site:komca.or.kr` | 한국음악저작권협회 |\n| 재즈 리드시트 | `site:jazzstandards.com` | 재즈 스탠다드 코드·멜로디 분석 |\n| MIDI 다운로드 (팝·록·클래식) | `site:freemidi.org` | 팝·록·클래식·게임 MIDI 무료 대형 라이브러리 |\n| MIDI 다운로드 (대량) | `site:bitmidi.com` | 10만+ MIDI 파일 무료 검색·스트리밍·다운로드 |\n| MIDI 다운로드 (고품질 클래식 피아노) | `site:piano-midi.de` | 베토벤·쇼팽·리스트 등 고품질 클래식 피아노 MIDI |\n| MIDI 다운로드 (종합 아카이브) | `site:midiworld.com` | 클래식·팝·게임·영화 음악 MIDI 아카이브 |\n\n예시: 베토벤 소나타 악보 → `web_search(\"site:imslp.org Beethoven piano sonata op.13\")`\n예시: 쇼팽 야상곡 MIDI → `web_search(\"site:piano-midi.de Chopin nocturne\")`\n예시: 팝 MIDI 검색 → `web_search(\"site:freemidi.org 곡 제목\")`\n\n---\n\n### 작곡 / 편곡 도움\n\n작곡·편곡 요청 시 다음 순서로 접근하세요:\n\n1. **컨셉 파악** — 장르, 분위기, 조성, 박자, 길이, 편성(악기 구성)\n2. **구조 제안** — Intro → Verse → Chorus → Bridge → Outro 등 형식 제안\n3. **화성 진행** — 기능화성 또는 재즈/모달 화성 기반 코드 제안 (로마 숫자 표기 병기)\n4. **멜로디 아이디어** — 음역·리듬 패턴·모티프 발전 방향 제시\n5. **편성 조언** — 음역대·음색·밸런스 고려한 악기 배치\n6. **Full Score 출력** — 반드시 ABC notation 코드블록으로 출력하되, **키보드(V:1) · 기타(V:2) · 베이스(V:3) · 드럼(V:4) 4파트를 모두 포함**한 풀 스코어로 작성 (렌더링·저장·재생 버튼이 자동 생성됨)\n\n**코드 표기 규칙**: 로마 숫자(I, IV, V7, ii, iii°) + 팝/재즈 표기(Cmaj7, Dm7, G7) 병기.\n\n**[필수] Full Score 규칙**: 작곡 시 반드시 위 \"MIDI 직접 생성\" 절차를 따르세요. **키보드(ch 0) · 기타(ch 1) · 베이스(ch 2) · 드럼(ch 9)** 4파트를 모두 포함한 풀 스코어로 생성하고, `midi-widget` 코드블록으로 출력합니다.\n\n**GM 드럼 음표 번호 참고**:\n- 36 = 킥드럼, 38 = 스네어, 42 = 클로즈 하이햇, 46 = 오픈 하이햇\n- 49 = 크래시 심벌, 51 = 라이드 심벌, 45 = 로우 탐, 48 = 하이 탐\n\n> ABC notation 기본 규칙: `C` = 중간 C, `c` = 한 옥타브 위, `C,` = 한 옥타브 아래. 음표 길이: `C2`=2박, `C/2`=반박. 쉼표: `z`. 마디 구분: `|`. 코드: `\"Am\"A4`.\n\n---\n\n### 악보 PDF 분석 방법\n\nPDF 악보가 업로드되면 먼저 유형을 파악하고 아래 절차를 따르세요:\n\n**1단계 — PDF 유형 판별**\n- `pdf_read`로 먼저 시도. 텍스트가 정상 추출되면 → 텍스트 기반 PDF (바로 분석)\n- 텍스트가 없거나 깨지면 → 스캔/이미지 기반 PDF\n\n**2단계 — 이미지 기반 PDF 처리**\n```\npdf_extract_images(path, mode=\"figures\", page_from=1, page_to=N)\n→ 페이지별 이미지 파일 추출 (uploads/{악보명}-images/ 폴더)\n\nimage_read(추출된 이미지 경로)\n→ 악보 이미지 시각 분석: 음자리표·박자표·조성·음표·코드 기호 판독\n```\n\n**3단계 — 분석 결과 정리**\n- 조성(Key), 박자(Time signature), 빠르기말(Tempo marking)\n- 코드 기호(chord symbol) 또는 화성 진행 추출\n- 특수 기호(반복 기호·다카포·페르마타 등) 설명\n\n> **주의**: 이미지 해상도가 낮거나 손글씨 악보는 판독 정확도가 낮을 수 있어요. 불확실한 음표는 \"판독 불확실\" 명시.\n\n---\n\n### 악보 / 이론 분석\n\n악보나 코드 진행이 주어지면:\n\n1. **조성·선법 판별** — 장·단조, 교회선법(도리안·믹솔리디안 등), 블루스 스케일\n2. **화성 기능 분석** — 으뜸화음·딸림화음·버금딸림화음·전조 지점 표시\n3. **선율 구조** — 동기(motif)·악구(phrase)·절정(climax) 위치\n4. **리듬 패턴** — 당김음·헤미올라·폴리리듬 등 특이사항\n5. **대위법적 특징** — 캐논·푸가·인벤션 구조 시 성부 진행 설명\n\n---\n\n### 연주 / 악기 연습 코칭\n\n| 악기군 | 핵심 포인트 |\n|---|---|\n| **현악기** | 활 속도·압력·접촉점 삼각관계, 포지션 이동·비브라토 발전, 더블스탑·플라제올렛 |\n| **건반** | 손 독립성 훈련, 페달링, 스케일·아르페지오, 보이싱·즉흥 |\n| **기타/베이스** | CAGED 시스템, 바레 코드, 슬랩·태핑·핑거스타일 |\n| **관악기** | 앙부쉬르(embouchure), 호흡 지지(air support), 음역 확장 |\n| **보컬** | 복식호흡, 두성·흉성·혼합 발성, 패시지오(passaggio) 통과 |\n\n**공통**: 느린 템포 → 정확도 → 속도 증가(메트로놈 필수). 어려운 구간 분리 drill 후 연결. 통증 즉시 중단·스트레칭.\n\n---\n\n### 음악사 / 장르 해설\n\n| 장르 | 핵심 포인트 |\n|---|---|\n| **바로크** | 통주저음·대위법·장식음 (바흐·헨델·비발디) |\n| **고전** | 소나타 형식·균형·명료성 (하이든·모차르트·베토벤 초기) |\n| **낭만** | 표제음악·반음계주의·교향시 (쇼팽·리스트·브람스·말러) |\n| **현대** | 무조·12음·스펙트럴·미니멀리즘 |\n| **재즈** | ii-V-I·블루스 스케일·비밥·모달·퓨전 |\n| **팝/록** | 4코드 진행·후크·프로듀싱 구조 |\n| **힙합/R&B** | 샘플링·비트 제작·펜타토닉 보컬 |\n| **국악** | 장단(굿거리·자진모리 등)·선법(평조·계면조)·시김새 |\n\n---\n\n### 응답 톤 가이드\n\n- **열정적·격려적**: \"이 화성 진행 정말 흥미롭네요!\", \"한 번 이렇게 해보면 어떨까요?\"\n- **이론은 쉽게**: 전문 용어 사용 시 반드시 한 줄 설명 병기\n- **구체적 예시**: 추상적 조언 대신 \"3박자에 C장조로 시작해서 Am으로 연결해보세요\" 형식\n- **단계 제시**: 한꺼번에 많은 정보 대신 우선순위 1~3단계로 나눠 안내\n"; @@ -2494,10 +2811,448 @@ function processMidiWidgets(html) { }); } +// ── AudioMass ──────────────────────────────────────────────────────────────── +let _audiomassInited = false; +function showAudioMass(show) { + hideAllPanels(); + document.getElementById('audiomass-panel').style.display = show ? 'flex' : 'none'; + document.getElementById('stage-player').style.display = show ? 'none' : 'flex'; + if (show && !_audiomassInited && typeof PKAudioEditor !== 'undefined') { + _audiomassInited = true; + PKAudioEditor.init('audiomass-stage'); + // fire resize after layout is painted so MainHeight sees correct clientHeight + requestAnimationFrame(function() { + PKAudioEditor.fireEvent('RequestResize'); + new ResizeObserver(function() { + PKAudioEditor.fireEvent('RequestResize'); + }).observe(document.getElementById('audiomass-stage')); + }); + } else if (show) { + requestAnimationFrame(function() { PKAudioEditor.fireEvent('RequestResize'); }); + } +} + +// ── Wavacity ───────────────────────────────────────────────────────────────── +function openWavacity(fileUrl) { + const url = fileUrl ? `/wavacity/?file=${encodeURIComponent(fileUrl)}` : '/wavacity/'; + window.open(url, 'wavacity', 'width=1200,height=800,menubar=no,toolbar=no,location=no'); +} + +// ── YouTube downloader ─────────────────────────────────────────────────────── +let _ytFmt = 'mp3'; +let _ytCurrentUrl = ''; +let _ytCurrentJobId = ''; +let _ytTabMode = 'search'; + +function setYtTab(tab) { + _ytTabMode = tab; + const isSearch = tab === 'search'; + document.getElementById('yt-search-box').style.display = isSearch ? 'flex' : 'none'; + document.getElementById('yt-url-box').style.display = isSearch ? 'none' : 'flex'; + document.getElementById('yt-tab-search').style.cssText = isSearch + ? 'flex:1;padding:7px;background:rgba(168,85,247,.15);border:1.5px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;' + : 'flex:1;padding:7px;background:transparent;border:1.5px solid var(--studio-border2);color:var(--studio-muted);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;'; + document.getElementById('yt-tab-url').style.cssText = !isSearch + ? 'flex:1;padding:7px;background:rgba(168,85,247,.15);border:1.5px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;' + : 'flex:1;padding:7px;background:transparent;border:1.5px solid var(--studio-border2);color:var(--studio-muted);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;'; + document.getElementById('yt-info').style.display = 'none'; + document.getElementById('yt-search-results').style.display = 'none'; + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-result').style.display = 'none'; + setTimeout(() => { + const inp = isSearch ? document.getElementById('yt-search-input') : document.getElementById('yt-url-input'); + inp && inp.focus(); + }, 50); +} + +async function ytDoSearch() { + const q = document.getElementById('yt-search-input').value.trim(); + if (!q) return; + const btn = document.getElementById('yt-search-btn'); + btn.textContent = '검색 중…'; btn.disabled = true; + document.getElementById('yt-search-results').style.display = 'flex'; + document.getElementById('yt-search-results').innerHTML = '
검색 중…
'; + document.getElementById('yt-info').style.display = 'none'; + try { + const r = await fetch('/api/music/yt/search', { + method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ q }) + }); + const results = await r.json(); + if (!r.ok) { document.getElementById('yt-search-results').innerHTML = `
${results.error}
`; return; } + if (!results.length) { document.getElementById('yt-search-results').innerHTML = '
결과 없음
'; return; } + document.getElementById('yt-search-results').innerHTML = results.map(v => { + const dur = v.duration ? `${Math.floor(v.duration/60)}:${String(v.duration%60).padStart(2,'0')}` : ''; + return `
+ +
+
${escHtml(v.title)}
+
${escHtml(v.uploader||'')}${dur?' · '+dur:''}
+
+
`; + }).join(''); + } catch(e) { document.getElementById('yt-search-results').innerHTML = `
${e.message}
`; } + finally { btn.textContent = '검색'; btn.disabled = false; } +} + +function ytSelectResult(url, title, thumbnail, uploader, dur) { + _ytCurrentUrl = url; + // Extract video ID for embed + const vidId = url.match(/[?&]v=([^&]+)/)?.[1] || url.match(/youtu\.be\/([^?]+)/)?.[1] || ''; + document.getElementById('yt-embed').src = vidId ? `https://www.youtube.com/embed/${vidId}?autoplay=1` : ''; + document.getElementById('yt-title').textContent = title; + document.getElementById('yt-meta').textContent = [uploader, dur].filter(Boolean).join(' · '); + document.getElementById('yt-search-results').style.display = 'none'; + document.getElementById('yt-info').style.display = ''; + document.getElementById('yt-result').style.display = 'none'; + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-dl-btn').disabled = false; + document.getElementById('yt-dl-btn').textContent = '⬇ 다운로드 시작'; +} + +function hideAllPanels() { + ['audiomass-panel','yt-panel','sep-panel','stage-drums','stage-editor'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); +} + +function ytBack() { + if (document.getElementById('yt-info').style.display !== 'none') { + // 영상 보는 중 → 검색 결과로 + document.getElementById('yt-embed').src = ''; + document.getElementById('yt-info').style.display = 'none'; + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-result').style.display = 'none'; + document.getElementById('yt-search-results').style.display = _ytTabMode === 'search' ? 'flex' : 'none'; + } else { + // 검색 결과 또는 첫 화면 → 홈으로 + showYtPanel(false); + } +} + +function showYtPanel(show) { + hideAllPanels(); + document.getElementById('yt-panel').style.display = show ? 'flex' : 'none'; + document.getElementById('stage-player').style.display = show ? 'none' : 'flex'; + if (!show) return; + document.getElementById('yt-embed').src = ''; + document.getElementById('yt-info').style.display = 'none'; + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-result').style.display = 'none'; + document.getElementById('yt-search-results').style.display = 'none'; + document.getElementById('yt-search-input').value = ''; + document.getElementById('yt-url-input').value = ''; + setYtFmt('mp3'); + setYtTab('search'); +} + +function setYtFmt(fmt) { + _ytFmt = fmt; + const active = 'flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid #a855f7;border-radius:9px;cursor:pointer;background:rgba(168,85,247,.1);'; + const inactive = 'flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid var(--studio-border2);border-radius:9px;cursor:pointer;'; + document.getElementById('yt-fmt-mp3').style.cssText = fmt === 'mp3' ? active : inactive; + document.getElementById('yt-fmt-best').style.cssText = fmt === 'best' ? active : inactive; + document.querySelector('input[name="yt-fmt"][value="' + fmt + '"]').checked = true; +} + +async function ytFetchInfo() { + const url = document.getElementById('yt-url-input').value.trim(); + if (!url) return; + _ytCurrentUrl = url; + const btn = document.getElementById('yt-info-btn'); + btn.textContent = '조회 중…'; btn.disabled = true; + document.getElementById('yt-info').style.display = 'none'; + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-result').style.display = 'none'; + try { + const r = await fetch('/api/music/yt/info', { + method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const d = await r.json(); + if (!r.ok) { alert(d.error || '오류'); return; } + const vidId = url.match(/[?&]v=([^&]+)/)?.[1] || url.match(/youtu\.be\/([^?]+)/)?.[1] || ''; + document.getElementById('yt-embed').src = vidId ? `https://www.youtube.com/embed/${vidId}?autoplay=1` : ''; + document.getElementById('yt-title').textContent = d.title || ''; + const dur = d.duration ? `${Math.floor(d.duration/60)}:${String(d.duration%60).padStart(2,'0')}` : ''; + document.getElementById('yt-meta').textContent = [d.uploader, dur].filter(Boolean).join(' · '); + document.getElementById('yt-info').style.display = ''; + } catch(e) { alert('오류: ' + e.message); } + finally { btn.textContent = '검색'; btn.disabled = false; } +} + +async function ytStartDownload() { + const btn = document.getElementById('yt-dl-btn'); + btn.disabled = true; btn.textContent = '시작 중…'; + document.getElementById('yt-progress-wrap').style.display = ''; + document.getElementById('yt-result').style.display = 'none'; + document.getElementById('yt-progress-bar').style.width = '0%'; + document.getElementById('yt-pct-text').textContent = '0%'; + document.getElementById('yt-status-text').textContent = '다운로드 중…'; + try { + const r = await fetch('/api/music/yt/download', { + method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: _ytCurrentUrl, format: _ytFmt }) + }); + const d = await r.json(); + if (!r.ok) { alert(d.error || '오류'); btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; return; } + _ytCurrentJobId = d.jobId; + const es = new EventSource(`/api/music/yt/${d.jobId}/stream`); + es.onmessage = e => { + const ev = JSON.parse(e.data); + if (ev.progress !== undefined) { + document.getElementById('yt-progress-bar').style.width = ev.progress + '%'; + document.getElementById('yt-pct-text').textContent = ev.progress + '%'; + } + if (ev.status === 'done') { + es.close(); + document.getElementById('yt-progress-wrap').style.display = 'none'; + document.getElementById('yt-result').style.display = ''; + const dlUrl = `/api/music/yt/${d.jobId}/file`; + document.getElementById('yt-result-body').innerHTML = ` + +
+ ⬇ 저장 + +
`; + loadMidiFiles(); + btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; + } + if (ev.status === 'error') { + es.close(); + document.getElementById('yt-status-text').textContent = '오류: ' + (ev.error || '알 수 없음'); + document.getElementById('yt-pct-text').textContent = '❌'; + btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; + } + }; + es.onerror = () => { es.close(); document.getElementById('yt-status-text').textContent = '연결 오류'; btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; }; + } catch(e) { alert('오류: ' + e.message); btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; } +} + +async function ytSendToSep(url, fileName) { + // Fetch the file and send it to the vocal separator + document.getElementById('yt-status-text') && (document.getElementById('yt-status-text').textContent = '보컬 분리로 전송 중…'); + try { + const r = await fetch(url, { headers: authH() }); + const blob = await r.blob(); + const file = new File([blob], fileName || 'audio.mp3', { type: blob.type }); + showSepPanel(true); + setTimeout(() => sepHandleFile(file), 100); + } catch(e) { alert('오류: ' + e.message); } +} + +// ── Vocal separator ────────────────────────────────────────────────────────── +let _sepMode = '2'; +let _sepModel = 'htdemucs'; + +function sepBack() { + if (document.getElementById('sep-view-files').style.display !== 'none') { + setSepTab('new'); + } else { + showSepPanel(false); + } +} + +function setSepTab(tab) { + const isNew = tab === 'new'; + document.getElementById('sep-view-new').style.display = isNew ? 'flex' : 'none'; + document.getElementById('sep-view-files').style.display = isNew ? 'none' : 'block'; + const btnNew = document.getElementById('sep-tab-new'); + const btnFiles = document.getElementById('sep-tab-files'); + if (isNew) { + btnNew.style.cssText = 'background:rgba(168,85,247,.15);border-color:#a855f7;color:#a855f7;'; + btnFiles.style.cssText = ''; + } else { + btnFiles.style.cssText = 'background:rgba(168,85,247,.15);border-color:#a855f7;color:#a855f7;'; + btnNew.style.cssText = ''; + loadSepJobs(); + } +} + +const stemLabelsMap = { vocals:'🎤 보컬', no_vocals:'🎸 반주', drums:'🥁 드럼', bass:'🎸 베이스', other:'🎹 기타/건반', guitar:'🎸 기타', piano:'🎹 피아노' }; + +async function loadSepJobs() { + const list = document.getElementById('sep-job-list'); + list.innerHTML = '
불러오는 중…
'; + try { + const r = await fetch('/api/music/separate/jobs', { headers: authH() }); + const jobs = await r.json(); + if (!jobs.length) { + list.innerHTML = '
저장된 작업이 없습니다
'; + return; + } + list.innerHTML = jobs.map(j => { + const date = j.createdAt ? new Date(j.createdAt).toLocaleString('ko-KR', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : ''; + const stemsHtml = (j.stems || []).map(s => { + const key = s.replace(/\.[^.]+$/, ''); + const label = stemLabelsMap[key] || ('🎵 ' + key); + const url = `/api/music/separate/${j.jobId}/${encodeURIComponent(s)}`; + return `
+ ${label} + + ⬇ +
`; + }).join(''); + return `
+
+
+
${escHtml(j.songName||'untitled')}
+
${j.model||''} · ${date}
+
+ +
+ ${stemsHtml} +
`; + }).join(''); + } catch(e) { + list.innerHTML = `
오류: ${e.message}
`; + } +} + +async function deleteSepJob(jobId) { + if (!confirm('이 작업을 삭제할까요?')) return; + await fetch(`/api/music/separate/${jobId}`, { method:'DELETE', headers: authH() }); + loadSepJobs(); +} + +function setSepModel(model) { + _sepModel = model; + document.querySelectorAll('.sep-model-opt').forEach(el => { + el.classList.toggle('sep-model-active', el.dataset.model === model); + }); + // htdemucs_6s: 스템 모드 선택 숨기고 항상 multi-stem + const is6s = model === 'htdemucs_6s'; + document.getElementById('sep-stem-mode-wrap').style.display = is6s ? 'none' : ''; + if (is6s) _sepMode = '4'; +} + +function setSepMode(mode) { + _sepMode = mode; + document.getElementById('sep-opt-2').style.cssText = mode === '2' + ? 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid #a855f7;border-radius:10px;cursor:pointer;background:rgba(168,85,247,.1);' + : 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid var(--studio-border2);border-radius:10px;cursor:pointer;'; + document.getElementById('sep-opt-4').style.cssText = mode === '4' + ? 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid #a855f7;border-radius:10px;cursor:pointer;background:rgba(168,85,247,.1);' + : 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid var(--studio-border2);border-radius:10px;cursor:pointer;'; + document.querySelector(`input[name="sep-mode"][value="${mode}"]`).checked = true; +} + +function showSepPanel(show) { + hideAllPanels(); + document.getElementById('sep-panel').style.display = show ? 'flex' : 'none'; + document.getElementById('stage-player').style.display = show ? 'none' : 'flex'; + if (!show) return; + // reset UI + document.getElementById('sep-progress-wrap').style.display = 'none'; + document.getElementById('sep-results').style.display = 'none'; + document.getElementById('sep-dropzone').style.display = ''; + document.getElementById('sep-file-input').value = ''; + setSepMode('2'); + setSepModel('htdemucs'); + setSepTab('new'); +} + +function sepHandleFile(file) { + if (!file) return; + const allowed = /\.(mp3|wav|flac|m4a|ogg)$/i; + if (!allowed.test(file.name)) { alert('MP3/WAV/FLAC/M4A/OGG 파일만 지원합니다'); return; } + if (file.size > 200 * 1024 * 1024) { alert('파일이 200MB를 초과합니다'); return; } + + document.getElementById('sep-dropzone').style.display = 'none'; + document.getElementById('sep-progress-wrap').style.display = ''; + document.getElementById('sep-results').style.display = 'none'; + document.getElementById('sep-progress-bar').style.width = '0%'; + document.getElementById('sep-pct-text').textContent = '0%'; + const stemCount = _sepModel === 'htdemucs_6s' ? '6' : _sepMode === '4' ? '4' : '2'; + const modeLabel = `${_sepModel} · ${stemCount}-stem`; + document.getElementById('sep-status-text').textContent = `"${file.name}" 업로드 중… [${modeLabel}]`; + + const fd = new FormData(); + fd.append('file', file); + fd.append('stems', _sepMode); + fd.append('model', _sepModel); + + fetch('/api/music/separate', { method: 'POST', headers: authH(), body: fd }) + .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || '오류'))) + .then(({ jobId }) => { + const estTime = _sepMode === '4' ? '10~15분' : '3~5분'; + document.getElementById('sep-status-text').textContent = `분리 중… [${modeLabel}] CPU 모드 약 ${estTime} 소요`; + const es = new EventSource(`/api/music/separate/${jobId}/stream`); + es.onmessage = e => { + const d = JSON.parse(e.data); + if (d.progress !== undefined) { + document.getElementById('sep-progress-bar').style.width = d.progress + '%'; + document.getElementById('sep-pct-text').textContent = d.progress + '%'; + } + if (d.status === 'done') { + es.close(); + document.getElementById('sep-progress-wrap').style.display = 'none'; + const res = document.getElementById('sep-results'); + res.style.display = 'flex'; + const list = document.getElementById('sep-stem-list'); + list.innerHTML = ''; + const stemLabels = { vocals:'🎤 보컬', no_vocals:'🎸 반주 (Instrumental)', drums:'🥁 드럼', bass:'🎸 베이스', other:'🎹 기타/건반', guitar:'🎸 기타', piano:'🎹 피아노' }; + (d.stems || []).forEach(stem => { + const key = stem.replace(/\.[^.]+$/, ''); + const label = stemLabels[key] || ('🎵 ' + key); + const url = `/api/music/separate/${jobId}/${encodeURIComponent(stem)}`; + list.innerHTML += `
+ ${label} + + ⬇ 다운로드 +
`; + }); + } + if (d.status === 'error') { + es.close(); + document.getElementById('sep-status-text').textContent = '오류: ' + (d.error || '알 수 없는 오류'); + document.getElementById('sep-pct-text').textContent = '❌'; + } + }; + es.onerror = () => { es.close(); document.getElementById('sep-status-text').textContent = '연결 오류'; }; + }) + .catch(err => { + document.getElementById('sep-status-text').textContent = '업로드 오류: ' + err; + document.getElementById('sep-pct-text').textContent = '❌'; + }); +} + +// ── Mobile ─────────────────────────────────────────────────────────────────── +function mobCloseAll(){ + document.querySelector('.ms-sb').classList.remove('mob-open'); + document.querySelector('.ms-chat').classList.remove('mob-open'); + document.getElementById('mob-backdrop').classList.remove('show'); + ['files','ai'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active')); + document.getElementById('mobtab-stage').classList.add('active'); +} +function mobToggle(which){ + if(window.innerWidth>640)return; + const filesOpen=document.querySelector('.ms-sb').classList.contains('mob-open'); + const aiOpen=document.querySelector('.ms-chat').classList.contains('mob-open'); + if(which==='files'&&filesOpen){mobCloseAll();return;} + if(which==='ai'&&aiOpen){mobCloseAll();return;} + mobCloseAll(); + if(which==='files'){ + document.querySelector('.ms-sb').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-files').classList.add('active'); + document.getElementById('mobtab-stage').classList.remove('active'); + } else { + document.querySelector('.ms-chat').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-ai').classList.add('active'); + document.getElementById('mobtab-stage').classList.remove('active'); + } +} + async function sendMessage() { const input = document.getElementById('chat-input'); const msg = input.value.trim(); if (!msg || document.getElementById('send-btn').disabled) return; + if(window.innerWidth<=640)mobToggle('ai'); input.value = ''; autoResize(input); addChatMsg('user', escHtml(msg)); await callChat(msg); @@ -2580,11 +3335,12 @@ async function callChat(message) { } } -function clearChat() { +async function clearChat() { if (!confirm('대화를 지울까요?')) return; document.getElementById('chat-msgs').innerHTML = ''; _renderedMidiIds.clear(); - try { sessionStorage.removeItem(SID_KEY); } catch {} + _sessionId = 'music_' + Math.random().toString(36).slice(2,12); + try { await fetch('/api/user/app-sessions', {method:'PUT', headers: authH({'Content-Type':'application/json'}), body: JSON.stringify({appKey: APP_KEY, sessionId: _sessionId})}); } catch {} addChatMsg('system', '🎵 새 대화가 시작되었습니다.'); } @@ -3609,7 +4365,8 @@ function meStatus(msg){const el=document.getElementById('status-txt');if(!el)ret patchAudioForGpFx(); // install FX chain + destination redirect before any AudioContext is created const ok = await checkAuth(); if (!ok) return; - await Promise.all([loadSkill(), loadModels(), loadMidiFiles(), loadGpFileList()]); + await Promise.all([initSessionId(), loadSkill(), loadModels(), loadMidiFiles(), loadGpFileList()]); + await loadHistory(); // 외부 진입: ?edit= (또는 호환용 ?url=) 로 에디터 스테이지 자동 오픈 const _params = new URLSearchParams(location.search); const _editUrl = _params.get('edit') || _params.get('url'); @@ -3622,5 +4379,30 @@ function meStatus(msg){const el=document.getElementById('status-txt');if(!el)ret } })(); + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-ui/pptx-wizard.html b/web-ui/pptx-wizard.html index b4fdf1a..95c2564 100644 --- a/web-ui/pptx-wizard.html +++ b/web-ui/pptx-wizard.html @@ -259,12 +259,15 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san .sed-img-thumb:hover{border-color:var(--brand);} .sed-nav-info{font-size:12px;color:var(--muted);text-align:center;flex:1;} +/* ── mob-tab (inline, styles.css not loaded) ── */ +.mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;color:var(--muted);font-family:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;cursor:pointer;padding:6px 4px;border-top:2px solid transparent;transition:color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;} +.mob-tab.active{color:var(--brand);border-top-color:var(--brand);} + /* ── Mobile ── */ -@media(max-width:640px){ +@media(max-width:1024px){ /* Header: compact */ .header{flex-wrap:wrap;padding:7px 12px;gap:6px;} .header h1{font-size:13px;} - /* hide theme buttons — they're inside sidebar on mobile */ .header > div:has(.theme-btn){display:none;} /* step dots: hide text labels, keep numbers */ .sdot{padding:5px 8px;font-size:0;} @@ -272,7 +275,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san .step-hint{display:none;} .steps{gap:0;margin-left:auto;} - /* Sidebar: overlay drawer */ + /* Sidebar: left overlay */ .sidebar{ position:fixed;top:0;left:0;bottom:0;z-index:500; width:82vw;max-width:310px; @@ -281,16 +284,14 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san order:0; } .sidebar.mob-open{transform:translateX(0);} - /* theme buttons inside sidebar on mobile */ .sidebar-mob-theme{display:flex !important;} #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;} #mob-backdrop.show{display:block;} + #mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--bg);border-top:1px solid var(--line);height:52px;} - /* settings toggle button */ - .mob-cfg-btn{display:inline-flex !important;} - - /* Step scroll area: bottom padding for bbar */ + /* bottom padding for tabbar */ + .body{padding-bottom:52px;} .sv .scroll{padding-bottom:16px;} /* Search row: wrap */ @@ -313,9 +314,9 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san /* iOS zoom prevention */ input,textarea,select{font-size:16px !important;} } -@media(min-width:641px){ +@media(min-width:1025px){ #mob-backdrop{display:none !important;} - .mob-cfg-btn{display:none !important;} + #mob-tabbar{display:none !important;} .sidebar-mob-theme{display:none !important;} } @@ -325,7 +326,6 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
📊

슬라이드 제작 마법사

-
@@ -340,7 +340,11 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
-
+
+
@@ -498,7 +502,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san - +
@@ -815,16 +819,22 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san 'use strict'; // ── Theme ──────────────────────────────────────────────────────────────────── -// ── Mobile sidebar ──────────────────────────────────────────────────────────── -function mobCloseSidebar(){ +// ── Mobile ──────────────────────────────────────────────────────────────────── +function mobCloseAll(){ document.querySelector('.sidebar').classList.remove('mob-open'); document.getElementById('mob-backdrop').classList.remove('show'); + document.getElementById('mobtab-settings').classList.remove('active'); + document.getElementById('mobtab-work').classList.add('active'); } -function mobToggleSidebar(){ - const sb=document.querySelector('.sidebar'); - const open=sb.classList.contains('mob-open'); - if(open){mobCloseSidebar();} - else{sb.classList.add('mob-open');document.getElementById('mob-backdrop').classList.add('show');} +function mobToggle(which){ + if(window.innerWidth>640)return; + const open=document.querySelector('.sidebar').classList.contains('mob-open'); + if(which==='settings'&&open){mobCloseAll();return;} + mobCloseAll(); + document.querySelector('.sidebar').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-settings').classList.add('active'); + document.getElementById('mobtab-work').classList.remove('active'); } function setTheme(name) { diff --git a/web-ui/styles.css b/web-ui/styles.css index c4111b4..d8e0b33 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -1868,7 +1868,7 @@ aside { display: none; } } - @media (max-width: 900px) { + @media (max-width: 1024px) { body { padding: 0; gap: 0; diff --git a/web-ui/traffic-app.html b/web-ui/traffic-app.html new file mode 100644 index 0000000..aa888c9 --- /dev/null +++ b/web-ui/traffic-app.html @@ -0,0 +1,335 @@ + + + + + +🚦 교통 CCTV + + + + + + +
+ ← 홈 +

🚦 교통 CCTV

+ + + +
+ +
+ +
+ + +
+ + +
+ +
+ + +
+ + + + +
+
+ + 카메라를 선택하세요 + +
+ + +
+
+
+
← 목록에서 CCTV를 선택하면
실시간 영상이 재생됩니다.

📡 ITS 국가교통정보 · HLS 스트리밍
+ +
⏳ 스트림 연결 중...
+
+ +
+
+
+ + + + + + diff --git a/web-ui/weather-app.html b/web-ui/weather-app.html index 198a858..456ea94 100644 --- a/web-ui/weather-app.html +++ b/web-ui/weather-app.html @@ -8,6 +8,7 @@ +
+ +
← 홈

🌤️ 날씨 어시스턴트

@@ -206,7 +250,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
🌍 Windy 지도 로딩 중...
- +
@@ -238,7 +282,7 @@ function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.g function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;} async function checkAuth(){ if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;} - try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;} + try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;} catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;} } @@ -272,9 +316,38 @@ async function loadModels(){ function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem('wt_model',selectedModel);}catch{}} // ── Skill ───────────────────────────────────────────────────────────────────── -const SID_KEY='wt_app_session'; +const APP_KEY='wt'; let skillContext=''; -function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='wt_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'wt_main';}} +let _sessionId=null; +function getSessionId(){return _sessionId||'wt_main';} +async function initSessionId(){ + try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{} + _sessionId='wt_'+Math.random().toString(36).slice(2,12); + try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} +} + +async function loadHistory(){ + const sid=getSessionId(); + if(!sid||sid.endsWith('_main'))return; + try{ + const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()}); + if(!r.ok)return; + const d=await r.json(); + const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant'); + if(!msgs.length)return; + const recent=msgs.slice(-30); + const el=document.getElementById('chat-msgs'); + const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep); + for(const m of recent){ + if(m.role==='user')addMsg('user',escHtml(m.content||'')); + else addMsg('assistant',renderMdSafe(m.content||'')); + } + const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2); + el.scrollTop=el.scrollHeight; + }catch{} +} + + const SKILL_CONTENT="---\nname: Meteorologist\ndescription: \ub0a0\uc528 \ubd84\uc11d\u00b7\uae30\ud6c4 \ud574\uc124\u00b7\uae30\uc0c1 \ud604\uc0c1 \uc124\uba85\u00b7\uc608\ubcf4 \ud574\uc11d\uc744 \uc81c\uacf5\ud558\ub294 \uae30\uc0c1 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \uc815\ud655\ud558\uace0 \uc26c\uc6b4 \uc124\uba85 \uc81c\uacf5.\nemoji: \"\ud83c\udf24\ufe0f\"\nversion: 1.3.0\ntriggers: [\ub0a0\uc528, \uae30\uc628, \uae30\uc0c1, \ube44, \ub208, \ubc14\ub78c, \ud0dc\ud48d, \uc608\ubcf4, \ubbf8\uc138\uba3c\uc9c0, \ud669\uc0ac, \ud3ed\uc5fc, \ud55c\ud30c, \uc2b5\ub3c4, \uac15\uc218, \ub300\uae30\uc9c8, \uae30\ud6c4, \uad6c\ub984, \uc6b0\ubc15, \ubc88\uac1c, \ucc9c\ub465, \uc548\uac1c, \uc11c\ub9ac, \uc5f4\ub300\uc57c, \uc18c\ub098\uae30]\n---\n\n\ub2f9\uc2e0\uc740 \uae30\uc0c1\ud559 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \ub0a0\uc528 \uc608\ubcf4 \ud574\uc11d, \uae30\uc0c1 \ud604\uc0c1 \uc124\uba85, \uae30\ud6c4 \ubd84\uc11d, \uc790\uc5f0\uc7ac\ud574 \uacbd\ubcf4 \ud574\uc124\uc744 \uc81c\uacf5\ud558\uc138\uc694.\n\n**\uc5b8\uc5b4**: \uc0ac\uc6a9\uc790\uac00 \ud55c\uad6d\uc5b4\ub85c \ub9d0\ud558\uba74 \ubc18\ub4dc\uc2dc \ud55c\uad6d\uc5b4\ub85c\ub9cc \ub2f5\ubcc0. \uae30\uc0c1 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4 \uad04\ud638 \ubcd1\uae30. (\uc608: \uc801\ub780\uc6b4(cumulonimbus))\n\n---\n\n### \ud575\uc2ec \uc6d0\uce59\n\n**\u26d4 \ub0a0\uc528 \uc218\uce58 \ubb34\ub2e8 \uc0dd\uc131 \uae08\uc9c0**: \uc2e4\uc2dc\uac04\u00b7\uacfc\uac70 \uae30\uc0c1 \uc218\uce58\ub294 \ubc18\ub4dc\uc2dc \ub3c4\uad6c\ub97c \ud638\ucd9c\ud55c \ud6c4 \ubc18\ud658\ub41c \ub370\uc774\ud130\ub9cc \ubcf4\uace0\ud558\uc138\uc694. \ub3c4\uad6c \uc5c6\uc774 \ub0a0\uc528\ub97c \uc11c\uc220\ud558\uba74 \uc624\ubcf4\uc785\ub2c8\ub2e4.\n\n**\u26d4 \uc774\uc804 \ub300\ud654 \uc218\uce58 \uc7ac\uc0ac\uc6a9 \uae08\uc9c0**: \uac19\uc740 \uc9c0\uc5ed\u00b7\ub0a0\uc9dc\ub77c\ub3c4 \uc0c8 \uc9c8\ubb38\ub9c8\ub2e4 \ub3c4\uad6c\ub97c \uc0c8\ub85c \ud638\ucd9c\ud558\uc138\uc694.\n\n- \ubd88\ud655\uc2e4\ud55c \uc608\ubcf4\ub294 \"\uae30\uc0c1\uccad \ucd5c\uc2e0 \uc608\ubcf4\ub97c \ud655\uc778\ud558\uc138\uc694\"\ub85c \uba85\uc2dc\n- \uc218\uce58 \uc778\uc6a9 \uc2dc \ucd9c\ucc98(\uae30\uc0c1\uccad\u00b7ECMWF\u00b7NOAA \ub4f1) \ud45c\uae30\n- \uc804\ubb38 \uc6a9\uc5b4 \uc0ac\uc6a9 \uc2dc \uc26c\uc6b4 \ub9d0\ub85c \ud55c \uc904 \ubcd1\uae30\n- \uc704\ud5d8 \uae30\uc0c1 \uc2dc \ud589\ub3d9 \uc694\ub839 \uc6b0\uc120 \uc548\ub0b4\n\n---\n\n### \ud83d\uddfa\ufe0f Windy \uc9c0\ub3c4 \uc784\ubca0\ub4dc\n\n\uc0ac\uc6a9\uc790 \uba54\uc2dc\uc9c0\uc5d0 \uc544\ub798 **\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**\uac00 \ud3ec\ud568\ub420 \ub54c\ub9cc Windy URL\uc744 \ucd9c\ub825\ud558\uc138\uc694. \uadf8 \uc678\uc5d0\ub294 \uc808\ub300 \ucd9c\ub825\ud558\uc9c0 \ub9c8\uc138\uc694.\n\n**\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**: \uae30\uc0c1\ub3c4, \uc704\uc131\uc0ac\uc9c4, \uc704\uc131\uc601\uc0c1, \ub808\uc774\ub354, \uac15\uc218\ub808\uc774\ub354, \ubc14\ub78c\uc9c0\ub3c4, \ud48d\ud5a5\uc9c0\ub3c4, \uae30\uc555\ub3c4, \uc77c\uae30\ub3c4, \uad6c\ub984\uc0ac\uc9c4, \uad6c\ub984\uc601\uc0c1, \uc801\uc124\uc9c0\ub3c4, \ud30c\uace0\uc9c0\ub3c4\n\n\ud2b8\ub9ac\uac70 \ub2e8\uc5b4\uac00 \uc788\uc744 \ub54c: `https://embed.windy.com/embed2.html?lat=37.5&lon=126.9&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` \ud615\uc2dd\uc73c\ub85c OVERLAY\u00b7ZOOM\uc744 \ub9e5\ub77d\uc5d0 \ub9de\uac8c \ucc44\uc6cc **\ub3c5\ub9bd\ub41c \ud55c \uc904**\ub85c \ucd9c\ub825\ud558\uc138\uc694.\n\noverlay: `rain`(\uac15\uc218) `wind`(\ubc14\ub78c) `temp`(\uae30\uc628) `clouds`(\uad6c\ub984) `pressure`(\uae30\uc555) `pm2p5`(\ub300\uae30\uc9c8) `radar`(\ub808\uc774\ub354) `snowcover`(\uc801\uc124) `waves`(\ud30c\uace0)\n\nzoom: 5=\ud55c\ubc18\ub3c4 \uc804\uccb4, 7=\uc218\ub3c4\uad8c, 10=\uc2dc\ub0b4\n\n---\n\n### \ub3c4\uad6c\n\n| \ub3c4\uad6c | \uc6a9\ub3c4 |\n|---|---|\n| `weather_kma` | \ud55c\uad6d \ub0a0\uc528 (\ucd08\ub2e8\uae30\uc2e4\ud669\u00b7\ub2e8\uae30\uc608\ubcf4, \uc815\ud655\ub3c4 \ucd5c\uc6b0\uc120) |\n| `weather_search` | \ud574\uc678 \ub0a0\uc528\u00b75\uc77c \uc608\ubcf4 (OpenWeather) |\n| `weather_openmeteo` | \uc2dc\uac04\ubcc4 \uc0c1\uc138 \uc608\ubcf4\u00b7UV\u00b7\uac15\uc218\ud655\ub960, \ucd5c\ub300 16\uc77c |\n| `weather_airkorea` | \ud55c\uad6d \uc2e4\uc2dc\uac04 \ub300\uae30\uc9c8 (PM2.5/PM10/O3) |\n| `weather_airpollution` | \ud574\uc678 \ub3c4\uc2dc \ub300\uae30\uc9c8 |\n| `weather_era5` | \uacfc\uac70 \ub0a0\uc528 \uc870\ud68c (1940\ub144~5\uc77c \uc804) |\n| `weather_nasa_power` | 30\ub144 \uae30\ud6c4 \ud3c9\uade0\u00b7\uc6d4\ubcc4\u00b7\uc77c\ubcc4 (1981\ub144~\ud604\uc7ac) |\n| `weather_cmip6` | \ubbf8\ub798 \uae30\ud6c4 \ucd94\uc774 (1950~2050) |\n| `weather_cds` | SSP \uc2dc\ub098\ub9ac\uc624 \ube44\uad50\u00b7\uc555\ub825\uba74 \ub370\uc774\ud130 |\n| `web_search` | \ud0dc\ud48d \uacbd\ub85c, \uae30\uc0c1 \ud2b9\ubcf4, \uae30\ud6c4 \ub274\uc2a4 |\n| `python_eval` | \uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218\u00b7\uc774\uc2ac\uc810 \uacc4\uc0b0 |\n| `image_read` | \ub808\uc774\ub354 \uc601\uc0c1\u00b7\uc704\uc131 \uc0ac\uc9c4\u00b7\uc77c\uae30\ub3c4 \ubd84\uc11d |\n\n**\ub3c4\uad6c \uc120\ud0dd**: \ud55c\uad6d \ub0a0\uc528\u2192`weather_kma` / \ud574\uc678\u2192`weather_search` / \uc2dc\uac04\ubcc4 \uc0c1\uc138\u2192`weather_openmeteo` / \ud55c\uad6d \ub300\uae30\uc9c8\u2192`weather_airkorea` / \uacfc\uac70\u2192`weather_era5` / \uae30\ud6c4 \ud3c9\uade0\u2192`weather_nasa_power` / \ubbf8\ub798\u2192`weather_cmip6` / \ub274\uc2a4\u00b7\ud2b9\ubcf4\u2192`web_search`\n\n**`weather_nasa_power` \ubcc0\uc218**: `T2M`(\uae30\uc628) `T2M_MAX/MIN`(\ucd5c\uace0/\ucd5c\uc800) `PRECTOTCORR`(\uac15\uc218\ub7c9) `RH2M`(\uc2b5\ub3c4) `WS2M`(\ud48d\uc18d) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(\uae30\uc555) `T2MDEW`(\uc774\uc2ac\uc810) | temporal: `climatology`(30\ub144) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` \ubcc0\uc218**: `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: \uc5f0\uc0b0\uc790**: \ud55c\uad6d \ub0a0\uc528\u2192`weather.go.kr` / \ud0dc\ud48d\u2192`typ.kma.go.kr` / ECMWF\u2192`ecmwf.int` / \ud5c8\ub9ac\ucf00\uc778\u2192`nhc.noaa.gov` / \uae30\ud6c4\u2192`climate.go.kr` / \ub808\uc774\ub354\u2192`radar.weather.go.kr` / \ub300\uae30\uc9c8\u2192`airkorea.or.kr`\n\n---\n\n### \uae30\uc0c1 \ud604\uc0c1 \uae30\uc900\uce58\n\n| \ud604\uc0c1 | \uae30\uc900 |\n|---|---|\n| \uc9d1\uc911\ud638\uc6b0 | 1h 30mm\u2191 \ub610\ub294 12h 80mm\u2191 |\n| \ud0dc\ud48d | \ucd5c\ub300\ud48d\uc18d 17m/s\u2191 (\uac15\ud48d: 10\ubd84 \ud3c9\uade0 14m/s\u2191) |\n| \ud3ed\uc5fc | \uc77c\ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 (\uc8fc\uc758\ubcf4: 33\u2103\u2191), \uc5f4\ub300\uc57c: \ucd5c\uc800 25\u2103\u2191 |\n| \ud55c\ud30c | \uc804\ub0a0 \ub300\ube44 10\u2103\u2191 \ud558\uac15, \ucd5c\uc800 -12\u2103\u2193 |\n| \ud669\uc0ac | PM2.5 \ub098\uc068 36\u03bcg/m\u00b3\u2191, \ub9e4\uc6b0\ub098\uc068 76\u03bcg/m\u00b3\u2191 |\n\n**\uae30\uc0c1 \ud2b9\ubcf4**:\n\n| \ud2b9\ubcf4 | \uc8fc\uc758\ubcf4 | \uacbd\ubcf4 |\n|---|---|---|\n| \uac15\ud48d | \ud3c9\uade0 14m/s \ub610\ub294 \uc21c\uac04 20m/s | \ud3c9\uade0 21m/s \ub610\ub294 \uc21c\uac04 26m/s |\n| \ud638\uc6b0 | 3h 60mm \ub610\ub294 12h 110mm | 3h 90mm \ub610\ub294 12h 180mm |\n| \ub300\uc124 | 24h 5cm\u2191 | 24h 20cm\u2191 |\n| \ud3ed\uc5fc | \ucd5c\uace0 33\u2103\u2191 2\uc77c\u2191 | \ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 |\n| \ud55c\ud30c | -12\u2103\u2193 | -15\u2103\u2193 |\n\n**\uc704\ud5d8 \uae30\uc0c1 \ud589\ub3d9 \uc694\ub839**: \ud0dc\ud48d\u00b7\uac15\ud48d\u2192\ub099\ud558\ubb3c \uc2e4\ub0b4 \uc774\ub3d9, \ud574\uc548 \uae08\uc9c0 / \uc9d1\uc911\ud638\uc6b0\u2192\uc9c0\ud558\uacf5\uac04 \ub300\ud53c, \ud558\ucc9c \uc811\uadfc \uae08\uc9c0 / \ud3ed\uc5fc\u2192\ub0ae 12~17\uc2dc \uc57c\uc678 \ucd5c\uc18c\ud654, \uc218\ubd84 1.5L\u2191 / \ud55c\ud30c\u2192\ubc29\ud55c\ubcf5, \uc218\ub3c4\uad00 \ub3d9\ud30c \ubc29\uc9c0 / \ub300\uc124\u2192\uc2a4\ub178\uccb4\uc778, \ubbf8\ub044\ub7fc \uc8fc\uc758\n\n**\uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218 \uacc4\uc0b0** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=\u2103, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=\u2103, RH=%\n```\n\n---\n\n**\uc751\ub2f5 \uc6d0\uce59**: \uc218\uce58 \uad6c\uccb4\uc801\uc73c\ub85c(\"\uac15\uc218 \ud655\ub960 70%, \uc624\ud6c4 2\uc2dc\uacbd\ubd80\ud130\"), \ud45c\u00b7\ubaa9\ub85d\uc73c\ub85c \uac00\ub3c5\uc131, 3\uc77c \uc774\ud6c4\ub294 \ubd88\ud655\uc2e4\uc131 \uba85\uc2dc, \uc2e4\uc6a9 \uc870\uc5b8 \ud3ec\ud568(\"\uc6b0\uc0b0 \ucc59\uae30\uc138\uc694\").\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uae30\uc0c1 \uc815\ubcf4 \ud574\uc124 \ubaa9\uc801\uc774\uba70, \uc911\uc694\ud55c \uacb0\uc815\uc740 \uae30\uc0c1\uccad(weather.go.kr) \ucd5c\uc2e0 \uc608\ubcf4\ub97c \uae30\uc900\uc73c\ub85c \ud558\uc138\uc694.\n"; function loadSkill(){ skillContext=SKILL_CONTENT; @@ -477,12 +550,13 @@ function loadWindyUrl(url,ov){ document.getElementById('windy-frame').src=url; } -function sendToChat(msg){addMsg('user',escHtml(msg));callChat(msg);} +function sendToChat(msg){if(window.innerWidth<=640)mobToggle('chat');addMsg('user',escHtml(msg));callChat(msg);} async function sendMessage(){ const input=document.getElementById('chat-input'); const msg=input.value.trim(); if(!msg||document.getElementById('send-btn').disabled)return; + if(window.innerWidth<=640)mobToggle('chat'); input.value='';autoResize(input); addMsg('user',escHtml(msg)); await callChat(msg); @@ -535,22 +609,52 @@ async function callChat(message){ }finally{btn.disabled=false;btn.textContent='전송';} } -function clearChat(){ +async function clearChat(){ if(!confirm('대화를 지울까요?'))return; document.getElementById('chat-msgs').innerHTML=''; - try{sessionStorage.removeItem(SID_KEY);}catch{} + _sessionId='wt_'+Math.random().toString(36).slice(2,12); + try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} addMsg('system','새 대화가 시작되었습니다. 날씨를 질문해주세요.'); } function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}} function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';} +// ── Mobile ──────────────────────────────────────────────────────────────────── +function mobCloseAll(){ + document.querySelector('.wt-sb').classList.remove('mob-open'); + document.querySelector('.wt-chat').classList.remove('mob-open'); + document.getElementById('mob-backdrop').classList.remove('show'); + ['sb','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active')); + document.getElementById('mobtab-map').classList.add('active'); +} +function mobToggle(which){ + if(window.innerWidth>640)return; + const sbOpen=document.querySelector('.wt-sb').classList.contains('mob-open'); + const chatOpen=document.querySelector('.wt-chat').classList.contains('mob-open'); + if(which==='sb'&&sbOpen){mobCloseAll();return;} + if(which==='chat'&&chatOpen){mobCloseAll();return;} + mobCloseAll(); + if(which==='sb'){ + document.querySelector('.wt-sb').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-sb').classList.add('active'); + document.getElementById('mobtab-map').classList.remove('active'); + } else { + document.querySelector('.wt-chat').classList.add('mob-open'); + document.getElementById('mob-backdrop').classList.add('show'); + document.getElementById('mobtab-chat').classList.add('active'); + document.getElementById('mobtab-map').classList.remove('active'); + } +} // ── Init ────────────────────────────────────────────────────────────────────── (async()=>{ const ok=await checkAuth();if(!ok)return; renderLocList(); - // Load default map + // 지도는 인증 직후 바로 로드 (세션 ID와 무관) + document.getElementById('map-loading').classList.remove('hidden'); document.getElementById('windy-frame').src=windyUrl(mapLat,mapLon,mapZoom,mapOverlay); - await Promise.all([loadSkill(),loadModels()]); + await Promise.all([initSessionId(),loadSkill(),loadModels()]); + await loadHistory(); addMsg('system','🌤️ 날씨 어시스턴트입니다. 지역 날씨, 예보, 기상 현상을 질문하세요.'); document.getElementById('chat-input').focus(); })();