v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화

- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 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 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-26 11:15:27 +09:00
co-authored by Claude Sonnet 4.6
parent cea916be78
commit d74614cee1
23 changed files with 3761 additions and 122 deletions
@@ -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.*
## hardware_history
- RTX 5060 launched May 19, 2025 ($299); RTX 5060 Ti launched April 16, 2025 ($429 for 16GB). [2026-06-24]
+7
View File
@@ -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
+103
View File
@@ -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분 유효)
+211
View File
@@ -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<string, string>;
}
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<string, string> };
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<KakaoChannelConfig>): void {
Object.assign(this.config, config);
}
/** 웹훅 핸들러. Express route에서 호출. */
async handleWebhook(body: OpenBuilderRequest): Promise<OpenBuilderResponse> {
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<string>((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<void> {
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<string> {
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<void> {
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);
}
}
+148
View File
@@ -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<void> {
if (!this.enabled) return;
const chunks = splitText(text, 1900);
for (const chunk of chunks) {
await this.sendChunk(chunk);
}
}
private async sendChunk(text: string): Promise<void> {
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<void> {
if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) return;
await this.doRefresh();
}
private async doRefresh(): Promise<void> {
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;
}
+376 -14
View File
@@ -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<string, string>; 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<string, SessionInfo>();
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<string, SessionInfo> = {};
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 <user-workspace>/.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<string, string> = {};
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 <user-workspace>/.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 <user-workspace>/.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('<html><body><h2>✅ 카카오 연동 완료!</h2><p>리프레시 토큰이 vault에 저장됐습니다. 이 창을 닫아도 됩니다.</p></body></html>');
} 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<string, string>;
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.
File diff suppressed because one or more lines are too long
+49 -4
View File
@@ -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';
+53
View File
@@ -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;}
}
</style>
<link rel="stylesheet" href="/vendor/codemirror/lib/codemirror.css">
<script src="/vendor/codemirror/lib/codemirror.js"></script>
@@ -316,6 +344,12 @@
</head>
<body>
<div id="mob-backdrop" onclick="mobCloseBoard()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-board" onclick="mobToggleBoard()"><span style="font-size:18px">🔌</span>보드</button>
<button class="mob-tab active" id="mobtab-serial" onclick="mobCloseBoard()"><span style="font-size:18px">📟</span>시리얼</button>
</div>
<div class="header">
<h1>🔌 Arduino Emulator</h1>
<span class="file" id="filename">sketch.ino</span>
@@ -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');
}
}
</script>
</body>
</html>
+75
View File
@@ -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;}
}
</style>
</head>
<body>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-files" onclick="mobToggle('files')"><span style="font-size:18px">📁</span>파일</button>
<button class="mob-tab active" id="mobtab-editor" onclick="mobCloseAll()"><span style="font-size:18px">💻</span>에디터</button>
<button class="mob-tab" id="mobtab-ai" onclick="mobToggle('ai')"><span style="font-size:18px">🤖</span>AI</button>
</div>
<div class="code-page-header">
<span style="font-size:16px">💻</span>
<h1>코드 에디터</h1>
@@ -591,6 +638,34 @@
</div>
<script src="code.js?v=20260604s"></script>
<script>
function mobCloseAll(){
document.querySelector('.code-sidebar-wrap').classList.remove('mob-open');
document.getElementById('code-ai-panel').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-editor').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>640)return;
const filesOpen=document.querySelector('.code-sidebar-wrap').classList.contains('mob-open');
const aiOpen=document.getElementById('code-ai-panel').classList.contains('mob-open');
if(which==='files'&&filesOpen){mobCloseAll();return;}
if(which==='ai'&&aiOpen){mobCloseAll();return;}
mobCloseAll();
if(which==='files'){
document.querySelector('.code-sidebar-wrap').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-files').classList.add('active');
document.getElementById('mobtab-editor').classList.remove('active');
} else {
document.getElementById('code-ai-panel').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-ai').classList.add('active');
document.getElementById('mobtab-editor').classList.remove('active');
}
}
</script>
<div id="csb-ctx"></div>
</body>
</html>
+2 -2
View File
@@ -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;}
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -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;}
}
</style>
</head>
<body>
<div id="mob-backdrop" onclick="mobClosePanel()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-ctrl" onclick="mobTogglePanel()"><span style="font-size:18px">⚡</span>플래시</button>
<button class="mob-tab active" id="mobtab-term" onclick="mobClosePanel()"><span style="font-size:18px">📟</span>터미널</button>
</div>
<div class="header">
<h1>⚡ ESP32 Flasher</h1>
<span class="file" id="filename">sketch.ino</span>
@@ -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');
}
}
</script>
</body>
</html>
+37
View File
@@ -62,7 +62,9 @@
<button class="mode-btn" onclick="window.open('/mind-app.html','_blank')" title="마음 상담실">🧠 상담</button>
<button class="mode-btn" onclick="window.open('/lawyer-app.html','_blank')" title="법률 어시스턴트">⚖️ 법률</button>
<button class="mode-btn" onclick="window.open('/investor-app.html','_blank')" title="투자 어시스턴트">📈 투자</button>
<button class="mode-btn" onclick="window.open('/detective-app.html','_blank')" title="탐정 어시스턴트">🔍 탐정</button>
<button class="mode-btn" onclick="window.open('/weather-app.html','_blank')" title="날씨 어시스턴트">🌤️ 날씨</button>
<button class="mode-btn" onclick="window.open('/traffic-app.html','_blank')" title="교통 CCTV">🚦 교통</button>
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
</div>
@@ -1345,6 +1347,7 @@
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">채널</label>
<select id="settings-channel-select" class="settings-input" onchange="onChannelTypeChange()" style="margin-bottom:10px">
<option value="telegram">Telegram</option>
<option value="kakao">카카오톡</option>
<option value="discord">Discord</option>
<option value="whatsapp">WhatsApp</option>
<option value="email">Email</option>
@@ -1354,6 +1357,10 @@
<div id="tg-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="tg-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="kk-status-bar" style="display:none;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="kk-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="kk-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="dc-status-bar" style="display:none;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="dc-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="dc-status-text" style="color:var(--muted)">미설정</span>
@@ -1378,6 +1385,23 @@
</label>
</div>
<div id="channel-form-kakao" style="display:none">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">REST API 키</label>
<input id="settings-kk-appkey" type="password" placeholder="카카오 REST API 키..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<div style="margin-top:10px;padding:10px;border:1px dashed var(--line);border-radius:8px;background:rgba(255,193,7,.05)">
<div style="font-size:11px;font-weight:700;color:var(--muted);margin-bottom:6px">OAuth 로그인 (나에게 보내기 알림용)</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">API 키 저장 후 아래 버튼으로 카카오 로그인하면 토큰이 자동 저장됩니다.</div>
<button onclick="kakaoOAuthStart()" style="background:#FEE500;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;color:#3C1E1E;cursor:pointer">🟡 카카오 로그인</button>
<span id="kk-oauth-status" style="font-size:11px;color:var(--muted);margin-left:8px"></span>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">사용자 매핑 (선택, JSON)</label>
<input id="settings-kk-usermap" type="text" placeholder='{"카카오userId":"homeclaw_username"}' style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:11px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:10px 0 0;cursor:pointer">
<input type="checkbox" id="settings-kk-channel-enabled" />
챗봇 채널 활성화 (오픈빌더 연동)
</label>
</div>
<div id="channel-form-discord" style="display:none">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Bot Token</label>
<input id="settings-dc-token" type="password" placeholder="Discord bot token" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
@@ -1485,6 +1509,19 @@
4. Add allowed IDs, then click <b>Test</b> and <b>Save</b>.<br>
5. Use <b>Send Test</b> to verify delivery.
</div>
<div id="channel-guide-kakao" style="display:none;font-size:12px;color:var(--muted);line-height:1.8">
<b>카카오톡 Setup</b><br>
<b>1.</b> <a href="https://developers.kakao.com" target="_blank" style="color:var(--brand)">developers.kakao.com</a> 에서 앱 생성<br>
<b>2.</b> 앱 설정 → 플랫폼 → Web 추가 (서버 도메인)<br>
<b>3.</b> 카카오 로그인 활성화 → 동의항목: <code style="background:#f4f4f4;padding:1px 4px;border-radius:3px">talk_message</code><br>
<b>4.</b> 허용된 리다이렉트 URI에 <code style="background:#f4f4f4;padding:1px 4px;border-radius:3px">{서버주소}/api/kakao/auth/callback</code> 추가<br>
<b>5.</b> REST API 키 입력 → 저장 → 🟡 카카오 로그인<br><br>
<b>챗봇 (오픈빌더)</b><br>
<b>6.</b> <a href="https://i.kakao.com" target="_blank" style="color:var(--brand)">i.kakao.com</a> 에서 챗봇 생성<br>
<b>7.</b> 스킬 서버 URL: <code style="background:#f4f4f4;padding:1px 4px;border-radius:3px">{서버주소}/api/kakao/webhook</code><br>
<b>8.</b> 스킬 편집 → 콜백 사용 체크 → 시나리오 폴백 블록에 스킬 연결<br>
<b>9.</b> 챗봇 채널 활성화 체크 → 저장
</div>
<div id="channel-guide-discord" style="display:none;font-size:12px;color:var(--muted);line-height:1.8">
<b>Discord Setup</b><br>
1. Create an app + bot in Discord Developer Portal.<br>
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -165,7 +165,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
/* ── Mobile ── */
@media(max-width:640px){
@media(max-width:1024px){
/* Header: wrap to 2 rows */
.lg-header { flex-wrap:wrap; padding:6px 10px; gap:5px; }
.lg-header h1 { font-size:13px; }
@@ -212,7 +212,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
input, textarea, select { font-size:16px !important; }
.quiz-fill { font-size:16px !important; }
}
@media(min-width:641px){
@media(min-width:1025px){
#mob-tabbar { display:none !important; }
#mob-backdrop { display:none !important; }
.mob-filter-btn { display:none !important; }
+39 -7
View File
File diff suppressed because one or more lines are too long
+39 -7
View File
@@ -156,7 +156,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.mn-resize:hover,.mn-resize.dragging{background:#8b5cf6;}
/* ── Mobile ── */
@media(max-width:640px){
@media(max-width:1024px){
.mn-hdr{flex-wrap:wrap;padding:5px 8px;gap:5px;}
.mn-hdr h1{font-size:12px;}
.mode-toggle{order:10;flex:0 0 100%;}
@@ -198,7 +198,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* font-size ≥16px to prevent iOS auto-zoom */
.mn-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;}
}
@@ -375,7 +375,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;}
}
@@ -472,8 +472,37 @@ async function loadModels(){
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
// ── Session ───────────────────────────────────────────────────────────────────
const SID_KEY='mn_app_session';
function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='mn_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'mn_main';}}
const APP_KEY='mn';
let _sessionId=null;
function getSessionId(){return _sessionId||'mn_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='mn_'+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{}
}
// ── Tools: show / hide ────────────────────────────────────────────────────────
let activeTool=null;
@@ -1067,10 +1096,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='mn_'+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();}}
@@ -1079,6 +1109,8 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
const ok=await checkAuth();if(!ok)return;
await initSessionId();
await loadHistory();
setMode(currentMode);
await Promise.all([loadSkills(),loadModels()]);
addMsg('system','🧠 마음 상담실에 오신 걸 환영합니다. 왼쪽 도구를 활용하거나 편하게 이야기해주세요.');
+792 -10
View File
File diff suppressed because one or more lines are too long
+30 -20
View File
@@ -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;}
}
</style>
@@ -325,7 +326,6 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<div class="header">
<span style="font-size:18px">📊</span>
<h1>슬라이드 제작 마법사</h1>
<button class="mob-cfg-btn theme-btn" style="display:none;padding:5px 10px" onclick="mobToggleSidebar()">⚙ 설정</button>
<div style="display:flex;gap:4px;margin-left:14px">
<button class="theme-btn active" id="tbtn-dark" onclick="setTheme('dark')">🌙 다크</button>
<button class="theme-btn" id="tbtn-light" onclick="setTheme('light')">☀️ 라이트</button>
@@ -340,7 +340,11 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
</div>
</div>
<div id="mob-backdrop" onclick="mobCloseSidebar()"></div>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-settings" onclick="mobToggle('settings')"><span style="font-size:18px">⚙</span>설정</button>
<button class="mob-tab active" id="mobtab-work" onclick="mobCloseAll()"><span style="font-size:18px">📊</span>작업</button>
</div>
<div class="body">
@@ -498,7 +502,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<button class="theme-btn" id="tbtn-dark2" onclick="setTheme('dark')" style="padding:2px 8px;font-size:11px">🌙</button>
<button class="theme-btn" id="tbtn-light2" onclick="setTheme('light')" style="padding:2px 8px;font-size:11px">☀️</button>
<button class="theme-btn" id="tbtn-warm2" onclick="setTheme('warm')" style="padding:2px 8px;font-size:11px">🌿</button>
<button onclick="mobCloseSidebar()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px;padding:0 4px;line-height:1">✕</button>
<button onclick="mobCloseAll()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px;padding:0 4px;line-height:1">✕</button>
</div>
</div>
<div class="scroll">
@@ -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) {
+1 -1
View File
@@ -1868,7 +1868,7 @@
aside { display: none; }
}
@media (max-width: 900px) {
@media (max-width: 1024px) {
body {
padding: 0;
gap: 0;
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🚦 교통 CCTV</title>
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#f59e0b;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.hdr h1{font-size:13px;font-weight:700;margin:0;}
.hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
/* ── 사이드바 ── */
.sidebar{width:280px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
.sb-top{padding:8px;border-bottom:1px solid var(--line);flex-shrink:0;}
.search-box{width:100%;background:var(--panel-2);border:1.5px solid var(--line);border-radius:7px;padding:5px 10px;font-size:12px;color:var(--text);font-family:inherit;outline:none;box-sizing:border-box;transition:.15s;}
.search-box:focus{border-color:var(--brand);}
.region-bar{display:flex;gap:4px;flex-wrap:wrap;padding:6px 8px;border-bottom:1px solid var(--line);flex-shrink:0;}
.r-btn{padding:3px 8px;border:1.5px solid var(--line);border-radius:12px;font-size:11px;font-weight:600;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;white-space:nowrap;}
.r-btn:hover{border-color:var(--brand);color:var(--brand);}
.r-btn.active{border-color:var(--brand);background:rgba(245,158,11,.12);color:var(--brand);}
.list-wrap{flex:1;overflow-y:auto;}
.list-wrap::-webkit-scrollbar{width:3px;}
.list-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.cctv-item{padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line);transition:.12s;}
.cctv-item:hover{background:rgba(245,158,11,.06);}
.cctv-item.active{background:rgba(245,158,11,.12);border-left:2px solid var(--brand);}
.ci-name{font-size:12px;font-weight:600;color:var(--text);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ci-road{font-size:10px;color:var(--muted);}
.list-empty{padding:20px;text-align:center;color:var(--muted);font-size:12px;}
/* ── 메인 뷰어 ── */
.main{flex:1;display:flex;flex-direction:column;min-width:0;}
.viewer-hdr{padding:8px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;align-items:center;gap:8px;}
.viewer-title{font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.status-dot{width:8px;height:8px;border-radius:50%;background:#888;flex-shrink:0;}
.status-dot.live{background:#22c55e;box-shadow:0 0 6px #22c55e;}
.btn-sm{padding:3px 10px;background:none;border:1.5px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.btn-sm:hover{border-color:var(--brand);color:var(--brand);}
.video-wrap{flex:1;position:relative;background:#000;display:flex;align-items:center;justify-content:center;min-height:0;}
.video-wrap video{width:100%;height:100%;object-fit:contain;}
.placeholder{color:#555;font-size:13px;text-align:center;padding:40px;line-height:1.8;}
.loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.7);color:#fff;font-size:13px;display:none;}
.loading-overlay.show{display:flex;}
/* ── 그리드 뷰 ── */
.grid-wrap{flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;}
.grid-wrap::-webkit-scrollbar{width:3px;}
.grid-wrap::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.grid-item{background:#111;border-radius:8px;overflow:hidden;cursor:pointer;border:1.5px solid var(--line);transition:.15s;aspect-ratio:4/3;position:relative;}
.grid-item:hover{border-color:var(--brand);}
.grid-item video{width:100%;height:100%;object-fit:cover;}
.grid-label{position:absolute;bottom:0;left:0;right:0;padding:4px 8px;background:rgba(0,0,0,.7);font-size:10px;color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.view-toggle{display:flex;gap:4px;}
.vt-btn{padding:3px 10px;background:none;border:1.5px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.vt-btn.active{border-color:var(--brand);color:var(--brand);background:rgba(245,158,11,.1);}
.count-badge{font-size:11px;color:var(--muted);padding:0 4px;}
/* ── 탭 ── */
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
.tab-btn{padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;border-bottom:2px solid transparent;transition:.15s;margin-bottom:-1px;}
.tab-btn:hover{color:var(--text);}
.tab-btn.active{color:var(--brand);border-bottom-color:var(--brand);}
.tab-pane{display:none;flex:1;min-height:0;}
.tab-pane.active{display:flex;flex-direction:column;}
/* ── UTIC iframe ── */
.utic-frame{flex:1;border:none;width:100%;height:100%;}
</style>
</head>
<body>
<header class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🚦 교통 CCTV</h1>
<span class="count-badge" id="count-badge"></span>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</header>
<div style="display:flex;flex-direction:column;flex:1;min-height:0;">
<!-- 탭 바 -->
<div class="tab-bar">
<button class="tab-btn active" id="tab-utic" onclick="switchTab('utic')">🗺️ UTIC 교통지도</button>
<button class="tab-btn" id="tab-its" onclick="switchTab('its')">📡 ITS 고속도로 CCTV</button>
</div>
<!-- UTIC 탭 -->
<div class="tab-pane active" id="pane-utic">
<iframe class="utic-frame" src="https://www.utic.go.kr/map/map.do?menu=cctv"
id="utic-frame" allowfullscreen></iframe>
</div>
<!-- ITS CCTV 탭 -->
<div class="tab-pane" id="pane-its" style="flex-direction:row;">
<!-- 사이드바: CCTV 목록 -->
<aside class="sidebar">
<div class="sb-top">
<input class="search-box" id="search" type="text" placeholder="🔍 도로·지역 검색..." oninput="filterList()">
</div>
<div class="region-bar" id="region-bar">
<button class="r-btn active" data-minX="124.0" data-maxX="132.0" data-minY="33.0" data-maxY="39.0" onclick="loadRegion(this)">전국</button>
<button class="r-btn" data-minX="126.7" data-maxX="127.2" data-minY="37.4" data-maxY="37.7" onclick="loadRegion(this)">서울</button>
<button class="r-btn" data-minX="126.6" data-maxX="127.5" data-minY="37.1" data-maxY="37.8" onclick="loadRegion(this)">경기</button>
<button class="r-btn" data-minX="128.8" data-maxX="129.3" data-minY="35.0" data-maxY="35.4" onclick="loadRegion(this)">부산</button>
<button class="r-btn" data-minX="128.5" data-maxX="129.0" data-minY="35.7" data-maxY="36.1" onclick="loadRegion(this)">대구</button>
<button class="r-btn" data-minX="126.6" data-maxX="127.0" data-minY="35.0" data-maxY="35.3" onclick="loadRegion(this)">광주</button>
<button class="r-btn" data-minX="127.3" data-maxX="127.6" data-minY="36.2" data-maxY="36.5" onclick="loadRegion(this)">대전</button>
<button class="r-btn" data-minX="128.5" data-maxX="129.3" data-minY="35.4" data-maxY="35.9" onclick="loadRegion(this)">울산</button>
<button class="r-btn" data-minX="126.3" data-maxX="126.9" data-minY="33.2" data-maxY="33.6" onclick="loadRegion(this)">제주</button>
</div>
<div class="list-wrap" id="list-wrap">
<div class="list-empty">지역을 선택하면 CCTV 목록이 로드됩니다.</div>
</div>
</aside>
<!-- 메인 뷰어 -->
<main class="main" id="main-area">
<div class="viewer-hdr" id="viewer-hdr">
<span class="status-dot" id="status-dot"></span>
<span class="viewer-title" id="viewer-title">카메라를 선택하세요</span>
<button class="btn-sm" id="btn-reload" onclick="reloadCurrent()" style="display:none">↺ 새로고침</button>
<div class="view-toggle" style="margin-left:auto;">
<button class="vt-btn active" id="btn-single" onclick="setView('single')">단일</button>
<button class="vt-btn" id="btn-grid" onclick="setView('grid')">그리드</button>
</div>
</div>
<div class="video-wrap" id="video-wrap">
<div class="placeholder" id="placeholder">← 목록에서 CCTV를 선택하면<br>실시간 영상이 재생됩니다.<br><br>📡 ITS 국가교통정보 · HLS 스트리밍</div>
<video id="player" style="display:none" controls muted autoplay playsinline></video>
<div class="loading-overlay" id="loading-overlay">⏳ 스트림 연결 중...</div>
</div>
<div class="grid-wrap" id="grid-wrap" style="display:none"></div>
</main>
</div>
</div>
<!-- hls.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script>
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
let allItems=[];
let currentUrl=null;
let currentHls=null;
let currentView='single';
let gridHlsList=[];
let lastRegionBtn=null;
function switchTab(t){
document.getElementById('tab-utic').classList.toggle('active',t==='utic');
document.getElementById('tab-its').classList.toggle('active',t==='its');
document.getElementById('pane-utic').classList.toggle('active',t==='utic');
document.getElementById('pane-its').classList.toggle('active',t==='its');
}
function setView(v){
currentView=v;
document.getElementById('btn-single').classList.toggle('active',v==='single');
document.getElementById('btn-grid').classList.toggle('active',v==='grid');
document.getElementById('video-wrap').style.display=v==='single'?'flex':'none';
document.getElementById('grid-wrap').style.display=v==='grid'?'grid':'none';
if(v==='grid') renderGrid();
}
// ── 지역 로드 ──
async function loadRegion(btn){
document.querySelectorAll('#region-bar .r-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
lastRegionBtn=btn;
const minX=btn.getAttribute('data-minX'),maxX=btn.getAttribute('data-maxX');
const minY=btn.getAttribute('data-minY'),maxY=btn.getAttribute('data-maxY');
try{localStorage.setItem('traffic_region',JSON.stringify({minX,maxX,minY,maxY,label:btn.textContent.trim()}));}catch{}
const listWrap=document.getElementById('list-wrap');
listWrap.innerHTML='<div class="list-empty">⏳ 로딩 중...</div>';
destroyAllGrid();
try{
const r=await fetch(`/api/traffic/cctv?minX=${minX}&maxX=${maxX}&minY=${minY}&maxY=${maxY}&cctvType=1`,{headers:authH()});
const d=await r.json();
allItems=d.items||[];
document.getElementById('count-badge').textContent=allItems.length+'개';
renderList(allItems);
if(currentView==='grid')renderGrid();
}catch(e){
listWrap.innerHTML=`<div class="list-empty">오류: ${e.message}</div>`;
}
}
// ── 목록 렌더링 ──
function renderList(items){
const wrap=document.getElementById('list-wrap');
if(!items.length){wrap.innerHTML='<div class="list-empty">해당 지역에 CCTV 없음</div>';return;}
wrap.innerHTML=items.map(it=>{
const origIdx=allItems.indexOf(it);
return `<div class="cctv-item" id="item-${origIdx}" onclick="playCctv(${origIdx})">
<div class="ci-name">${escHtml(it.cctvname||'')}</div>
<div class="ci-road">📍 ${it.cctvformat||'HLS'} · ${it.coordy?.toFixed(4)}, ${it.coordx?.toFixed(4)}</div>
</div>`;
}).join('');
}
function filterList(){
const q=document.getElementById('search').value.toLowerCase();
const filtered=q?allItems.filter(it=>(it.cctvname||'').toLowerCase().includes(q)):allItems;
renderList(filtered);
}
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ── 단일 재생 ──
function playCctv(idx){
document.querySelectorAll('.cctv-item').forEach(el=>el.classList.remove('active'));
const el=document.getElementById('item-'+idx);
if(el)el.classList.add('active');
const it=allItems[idx];
if(!it)return;
document.getElementById('viewer-title').textContent=it.cctvname||'CCTV';
document.getElementById('btn-reload').style.display='';
currentUrl=it.cctvurl;
playHls(currentUrl, document.getElementById('player'), document.getElementById('status-dot'));
}
function reloadCurrent(){if(currentUrl)playHls(currentUrl, document.getElementById('player'), document.getElementById('status-dot'));}
function proxyUrl(url){return '/api/traffic/hls-proxy?url='+encodeURIComponent(url);}
function playHls(url, videoEl, dotEl){
url=proxyUrl(url);
document.getElementById('placeholder').style.display='none';
videoEl.style.display='block';
document.getElementById('loading-overlay').classList.add('show');
if(dotEl)dotEl.className='status-dot';
if(currentHls){currentHls.destroy();currentHls=null;}
if(Hls.isSupported()){
const hls=new Hls({enableWorker:false});
currentHls=hls;
hls.loadSource(url);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED,()=>{
videoEl.play().catch(()=>{});
document.getElementById('loading-overlay').classList.remove('show');
if(dotEl)dotEl.className='status-dot live';
});
hls.on(Hls.Events.ERROR,(_,data)=>{
if(data.fatal){
document.getElementById('loading-overlay').classList.remove('show');
if(dotEl)dotEl.className='status-dot';
}
});
} else if(videoEl.canPlayType('application/vnd.apple.mpegurl')){
videoEl.src=url;
videoEl.play().catch(()=>{});
document.getElementById('loading-overlay').classList.remove('show');
if(dotEl)dotEl.className='status-dot live';
}
}
// ── 그리드 뷰 (최대 9개) ──
function destroyAllGrid(){
gridHlsList.forEach(h=>h.destroy());
gridHlsList=[];
}
function renderGrid(){
destroyAllGrid();
const wrap=document.getElementById('grid-wrap');
const items=allItems.slice(0,9);
if(!items.length){wrap.innerHTML='<div style="color:var(--muted);font-size:12px;grid-column:1/-1;text-align:center;padding:20px">CCTV 없음</div>';return;}
wrap.innerHTML=items.map((it,i)=>`
<div class="grid-item" onclick="switchToSingle(${i})">
<video id="gvid-${i}" muted autoplay playsinline></video>
<div class="grid-label">${escHtml(it.cctvname||'')}</div>
</div>`).join('');
items.forEach((it,i)=>{
const v=document.getElementById('gvid-'+i);
if(!v||!it.cctvurl)return;
const purl=proxyUrl(it.cctvurl);
if(Hls.isSupported()){
const hls=new Hls({enableWorker:false,maxBufferLength:10});
gridHlsList.push(hls);
hls.loadSource(purl);
hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));
} else if(v.canPlayType('application/vnd.apple.mpegurl')){
v.src=purl;
v.play().catch(()=>{});
}
});
}
function switchToSingle(idx){
setView('single');
playCctv(idx);
}
// ── Init ──
(async()=>{
// 저장된 지역 복원, 없으면 전국
let restored=false;
try{
const saved=JSON.parse(localStorage.getItem('traffic_region')||'null');
if(saved){
const btn=document.querySelector(
`.r-btn[data-minX="${saved.minX}"][data-maxX="${saved.maxX}"][data-minY="${saved.minY}"][data-maxY="${saved.maxY}"]`
);
if(btn){loadRegion(btn);restored=true;}
}
}catch{}
if(!restored){
const btn=document.querySelector('.r-btn[data-minX="124.0"]');
if(btn)loadRegion(btn);
}
})();
</script>
</body>
</html>
+113 -9
View File
File diff suppressed because one or more lines are too long