clawmate 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/electron-builder.yml +1 -1
- package/index.js +945 -17
- package/main/ai-bridge.js +76 -16
- package/main/ai-connector.js +94 -11
- package/main/autostart.js +3 -3
- package/main/file-command-parser.js +360 -0
- package/main/index.js +19 -5
- package/main/ipc-handlers.js +107 -2
- package/main/platform.js +48 -1
- package/main/smart-file-ops.js +373 -0
- package/main/telegram.js +593 -0
- package/main/tray.js +307 -39
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
- package/preload/preload.js +19 -3
- package/renderer/first-run.html +2 -2
- package/renderer/index.html +2 -0
- package/renderer/js/ai-controller.js +312 -7
- package/renderer/js/app.js +19 -6
- package/renderer/js/browser-watcher.js +172 -0
- package/renderer/js/character.js +119 -22
- package/renderer/js/interactions.js +45 -2
- package/renderer/js/memory.js +108 -1
- package/renderer/js/metrics.js +607 -0
- package/renderer/js/mode-manager.js +53 -9
- package/renderer/js/pet-engine.js +372 -30
- package/renderer/js/state-machine.js +7 -0
- package/renderer/launcher.html +3 -3
- package/shared/messages.js +110 -0
- package/shared/personalities.js +37 -2
- package/skills/launch-pet/index.js +1 -1
- package/skills/launch-pet/skill.json +1 -1
package/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClawMate
|
|
2
|
+
* ClawMate 플러그인 진입점
|
|
3
3
|
*
|
|
4
|
-
* 핵심 원칙:
|
|
4
|
+
* 핵심 원칙: AI가 연결되면 자동으로 ClawMate를 찾아서 연결.
|
|
5
5
|
*
|
|
6
6
|
* 흐름:
|
|
7
|
-
*
|
|
7
|
+
* 플러그인 로드 → init() 자동 호출
|
|
8
8
|
* → ClawMate 실행 중인지 확인 (ws://127.0.0.1:9320 연결 시도)
|
|
9
9
|
* → 실행 중이면: 바로 연결, AI 뇌 역할 시작
|
|
10
10
|
* → 안 돌고 있으면: Electron 앱 자동 실행 → 연결
|
|
@@ -14,7 +14,7 @@ const { spawn } = require('child_process');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const os = require('os');
|
|
16
16
|
const fs = require('fs');
|
|
17
|
-
const {
|
|
17
|
+
const { ClawMateConnector } = require('./main/ai-connector');
|
|
18
18
|
|
|
19
19
|
let connector = null;
|
|
20
20
|
let electronProcess = null;
|
|
@@ -30,6 +30,16 @@ let lastDesktopCheckTime = 0;
|
|
|
30
30
|
let lastScreenCheckTime = 0;
|
|
31
31
|
let lastGreetingDate = null; // 하루에 한번만 인사
|
|
32
32
|
|
|
33
|
+
// 브라우징 감시 시스템 상태
|
|
34
|
+
let browsingContext = {
|
|
35
|
+
title: '', // 현재 윈도우 제목
|
|
36
|
+
category: '', // 카테고리 (shopping, video, dev 등)
|
|
37
|
+
lastCommentTime: 0, // 마지막 AI 코멘트 시각
|
|
38
|
+
screenImage: null, // 최근 화면 캡처 (base64)
|
|
39
|
+
cursorX: 0, // 커서 X 좌표
|
|
40
|
+
cursorY: 0, // 커서 Y 좌표
|
|
41
|
+
};
|
|
42
|
+
|
|
33
43
|
// 공간 탐험 시스템 상태
|
|
34
44
|
let knownWindows = []; // 알고 있는 윈도우 목록
|
|
35
45
|
let lastWindowCheckTime = 0;
|
|
@@ -38,14 +48,31 @@ let explorationHistory = []; // 탐험한 위치 기록
|
|
|
38
48
|
let lastExploreTime = 0;
|
|
39
49
|
let lastFolderCarryTime = 0;
|
|
40
50
|
|
|
51
|
+
// =====================================================
|
|
52
|
+
// 자기 관찰 시스템 상태 (Metrics)
|
|
53
|
+
// =====================================================
|
|
54
|
+
let latestMetrics = null; // 가장 최근 수신한 메트릭 데이터
|
|
55
|
+
let metricsHistory = []; // 최근 10개 메트릭 보고 이력
|
|
56
|
+
let behaviorAdjustments = { // 현재 적용 중인 행동 조정값
|
|
57
|
+
speechCooldownMultiplier: 1.0, // 말풍선 빈도 조절 (1.0=기본, >1=줄임, <1=늘림)
|
|
58
|
+
actionCooldownMultiplier: 1.0, // 행동 빈도 조절
|
|
59
|
+
explorationBias: 0, // 탐험 편향 (양수=더 탐험, 음수=덜 탐험)
|
|
60
|
+
activityLevel: 1.0, // 활동 수준 (0.5=차분, 1.0=보통, 1.5=활발)
|
|
61
|
+
};
|
|
62
|
+
let lastMetricsLogTime = 0; // 마지막 품질 보고서 로그 시각
|
|
63
|
+
|
|
64
|
+
// AI 모션 생성 시스템 상태
|
|
65
|
+
let lastMotionGenTime = 0; // 마지막 모션 생성 시각
|
|
66
|
+
let generatedMotionCount = 0; // 생성된 모션 수
|
|
67
|
+
|
|
41
68
|
module.exports = {
|
|
42
69
|
id: 'clawmate',
|
|
43
70
|
name: 'ClawMate',
|
|
44
|
-
version: '1.
|
|
45
|
-
description: '
|
|
71
|
+
version: '1.4.0',
|
|
72
|
+
description: 'ClawMate 데스크톱 펫 - AI가 조종하는 살아있는 펫',
|
|
46
73
|
|
|
47
74
|
/**
|
|
48
|
-
*
|
|
75
|
+
* 플러그인 로드 시 자동 호출
|
|
49
76
|
* → ClawMate 자동 실행 + 자동 연결
|
|
50
77
|
*/
|
|
51
78
|
async init(api) {
|
|
@@ -116,10 +143,38 @@ module.exports = {
|
|
|
116
143
|
connector.decide(context.params);
|
|
117
144
|
},
|
|
118
145
|
});
|
|
146
|
+
|
|
147
|
+
// 스마트 파일 정리 (텔레그램에서 트리거 가능)
|
|
148
|
+
api.registerSkill('pet-file-organize', {
|
|
149
|
+
triggers: [
|
|
150
|
+
'바탕화면 정리', '파일 정리', '파일 옮겨',
|
|
151
|
+
'organize desktop', 'clean desktop', 'move files',
|
|
152
|
+
],
|
|
153
|
+
description: '펫이 바탕화면 파일을 정리합니다',
|
|
154
|
+
execute: async (context) => {
|
|
155
|
+
if (!connector || !connector.connected) {
|
|
156
|
+
return { message: 'ClawMate 연결 중이 아닙니다.' };
|
|
157
|
+
}
|
|
158
|
+
const text = context.params?.text || context.input;
|
|
159
|
+
const { parseMessage } = require('./main/file-command-parser');
|
|
160
|
+
const parsed = parseMessage(text);
|
|
161
|
+
|
|
162
|
+
if (parsed.type === 'smart_file_op') {
|
|
163
|
+
// smart_file_op 명령을 커넥터를 통해 Electron 측에 전달
|
|
164
|
+
connector._send('smart_file_op', {
|
|
165
|
+
command: parsed,
|
|
166
|
+
fromPlugin: true,
|
|
167
|
+
});
|
|
168
|
+
return { message: `파일 정리 시작: ${text}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { message: '파일 정리 명령을 이해하지 못했습니다.' };
|
|
172
|
+
},
|
|
173
|
+
});
|
|
119
174
|
},
|
|
120
175
|
|
|
121
176
|
/**
|
|
122
|
-
*
|
|
177
|
+
* 플러그인 종료 시 정리
|
|
123
178
|
*/
|
|
124
179
|
async destroy() {
|
|
125
180
|
console.log('[ClawMate] 플러그인 정리');
|
|
@@ -137,7 +192,7 @@ module.exports = {
|
|
|
137
192
|
// =====================================================
|
|
138
193
|
|
|
139
194
|
/**
|
|
140
|
-
*
|
|
195
|
+
* 플러그인 시작 시 자동으로 ClawMate 찾기/실행/연결
|
|
141
196
|
* 무한 재시도 — ClawMate가 살아있는 한 항상 연결 유지
|
|
142
197
|
*/
|
|
143
198
|
async function autoConnect() {
|
|
@@ -163,7 +218,7 @@ async function autoConnect() {
|
|
|
163
218
|
function tryConnect() {
|
|
164
219
|
return new Promise((resolve) => {
|
|
165
220
|
if (!connector) {
|
|
166
|
-
connector = new
|
|
221
|
+
connector = new ClawMateConnector(9320);
|
|
167
222
|
setupConnectorEvents();
|
|
168
223
|
}
|
|
169
224
|
|
|
@@ -231,6 +286,11 @@ function setupConnectorEvents() {
|
|
|
231
286
|
await handleUserEvent(event);
|
|
232
287
|
});
|
|
233
288
|
|
|
289
|
+
// 메트릭 리포트 수신 → 자기 관찰 시스템에서 분석
|
|
290
|
+
connector.onMetrics((data) => {
|
|
291
|
+
handleMetrics(data);
|
|
292
|
+
});
|
|
293
|
+
|
|
234
294
|
// 윈도우 위치 정보 수신 → 탐험 시스템에서 활용
|
|
235
295
|
connector.on('window_positions', (data) => {
|
|
236
296
|
knownWindows = data.windows || [];
|
|
@@ -252,7 +312,7 @@ function setupConnectorEvents() {
|
|
|
252
312
|
*/
|
|
253
313
|
function onConnected() {
|
|
254
314
|
if (connector && connector.connected) {
|
|
255
|
-
connector.speak('
|
|
315
|
+
connector.speak('AI 연결됨! 같이 놀자!');
|
|
256
316
|
connector.action('excited');
|
|
257
317
|
|
|
258
318
|
// "집" 위치 설정 — 화면 하단 왼쪽을 기본 홈으로
|
|
@@ -331,6 +391,14 @@ async function handleUserEvent(event) {
|
|
|
331
391
|
}
|
|
332
392
|
break;
|
|
333
393
|
|
|
394
|
+
case 'double_click':
|
|
395
|
+
connector.decide({
|
|
396
|
+
action: 'excited',
|
|
397
|
+
speech: '우와! 더블클릭이다! 기분 좋아~',
|
|
398
|
+
emotion: 'happy',
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
|
|
334
402
|
case 'drag':
|
|
335
403
|
connector.speak('으앗, 나를 옮기다니!');
|
|
336
404
|
break;
|
|
@@ -375,6 +443,14 @@ async function handleUserEvent(event) {
|
|
|
375
443
|
});
|
|
376
444
|
}
|
|
377
445
|
break;
|
|
446
|
+
|
|
447
|
+
case 'browsing':
|
|
448
|
+
handleBrowsingComment(event);
|
|
449
|
+
break;
|
|
450
|
+
|
|
451
|
+
case 'character_request':
|
|
452
|
+
handleCharacterRequest(event);
|
|
453
|
+
break;
|
|
378
454
|
}
|
|
379
455
|
}
|
|
380
456
|
|
|
@@ -382,6 +458,496 @@ function sleep(ms) {
|
|
|
382
458
|
return new Promise(r => setTimeout(r, ms));
|
|
383
459
|
}
|
|
384
460
|
|
|
461
|
+
// =====================================================
|
|
462
|
+
// 브라우징 AI 코멘트 시스템
|
|
463
|
+
// 윈도우 제목 + 화면 캡처 + 커서 위치 기반 맥락 코멘트
|
|
464
|
+
// =====================================================
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 브라우징 컨텍스트 수신 → AI 코멘트 생성
|
|
468
|
+
*
|
|
469
|
+
* 렌더러(BrowserWatcher)가 감지한 브라우징 활동을 받아서
|
|
470
|
+
* 화면 캡처와 제목을 분석하여 맥락에 맞는 코멘트를 생성한다.
|
|
471
|
+
*
|
|
472
|
+
* @param {object} event - { title, category, cursorX, cursorY, screen?, titleChanged }
|
|
473
|
+
*/
|
|
474
|
+
async function handleBrowsingComment(event) {
|
|
475
|
+
if (!connector || !connector.connected) return;
|
|
476
|
+
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
// AI 코멘트 쿨다운 (45초)
|
|
479
|
+
if (now - browsingContext.lastCommentTime < 45000) return;
|
|
480
|
+
|
|
481
|
+
browsingContext.title = event.title || '';
|
|
482
|
+
browsingContext.category = event.category || '';
|
|
483
|
+
browsingContext.cursorX = event.cursorX || 0;
|
|
484
|
+
browsingContext.cursorY = event.cursorY || 0;
|
|
485
|
+
|
|
486
|
+
// 화면 캡처 데이터 저장 (있으면)
|
|
487
|
+
if (event.screen?.image) {
|
|
488
|
+
browsingContext.screenImage = event.screen.image;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let comment = null;
|
|
492
|
+
|
|
493
|
+
// 1차: apiRef.generate()로 AI 텍스트 생성 시도
|
|
494
|
+
if (apiRef?.generate) {
|
|
495
|
+
try {
|
|
496
|
+
const prompt = buildBrowsingPrompt(event);
|
|
497
|
+
comment = await apiRef.generate(prompt);
|
|
498
|
+
// 너무 긴 응답은 자르기
|
|
499
|
+
if (comment && comment.length > 50) {
|
|
500
|
+
comment = comment.slice(0, 50);
|
|
501
|
+
}
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 2차: apiRef.chat()으로 시도
|
|
506
|
+
if (!comment && apiRef?.chat) {
|
|
507
|
+
try {
|
|
508
|
+
const prompt = buildBrowsingPrompt(event);
|
|
509
|
+
const response = await apiRef.chat([
|
|
510
|
+
{ role: 'system', content: '넌 데스크톱 위의 작은 펫이야. 짧고 재치있게 한마디 해. 20자 이내. 한국어.' },
|
|
511
|
+
{ role: 'user', content: prompt },
|
|
512
|
+
]);
|
|
513
|
+
comment = response?.text || response?.content || response;
|
|
514
|
+
if (comment && typeof comment === 'string' && comment.length > 50) {
|
|
515
|
+
comment = comment.slice(0, 50);
|
|
516
|
+
}
|
|
517
|
+
} catch {}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 3차: 이미지 분석으로 시도 (화면 캡처가 있을 때)
|
|
521
|
+
if (!comment && apiRef?.analyzeImage && browsingContext.screenImage) {
|
|
522
|
+
try {
|
|
523
|
+
comment = await apiRef.analyzeImage(browsingContext.screenImage, {
|
|
524
|
+
prompt: `사용자가 "${browsingContext.title}"을 보고 있어. 커서 위치: (${browsingContext.cursorX}, ${browsingContext.cursorY}). 데스크톱 펫으로서 화면 내용에 대해 재치있게 한마디 해줘. 20자 이내. 한국어.`,
|
|
525
|
+
});
|
|
526
|
+
} catch {}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 4차: 스마트 폴백 — 타이틀 분석 기반 코멘트
|
|
530
|
+
if (!comment || typeof comment !== 'string') {
|
|
531
|
+
comment = generateSmartBrowsingComment(browsingContext);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (comment) {
|
|
535
|
+
connector.decide({
|
|
536
|
+
action: Math.random() < 0.3 ? 'excited' : 'idle',
|
|
537
|
+
speech: comment,
|
|
538
|
+
emotion: 'curious',
|
|
539
|
+
});
|
|
540
|
+
browsingContext.lastCommentTime = now;
|
|
541
|
+
lastSpeechTime = now;
|
|
542
|
+
console.log(`[ClawMate] 브라우징 코멘트: ${comment}`);
|
|
543
|
+
|
|
544
|
+
// 1.5초 후 원래 상태로
|
|
545
|
+
setTimeout(() => {
|
|
546
|
+
if (connector?.connected) connector.action('idle');
|
|
547
|
+
}, 1500);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 캡처 데이터 정리 (메모리 절약)
|
|
551
|
+
browsingContext.screenImage = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* AI 코멘트 생성용 프롬프트 구성
|
|
556
|
+
*/
|
|
557
|
+
function buildBrowsingPrompt(event) {
|
|
558
|
+
const title = event.title || '';
|
|
559
|
+
const category = event.category || 'unknown';
|
|
560
|
+
const cursor = event.cursorX && event.cursorY
|
|
561
|
+
? `커서 위치: (${event.cursorX}, ${event.cursorY}).`
|
|
562
|
+
: '';
|
|
563
|
+
|
|
564
|
+
return `사용자가 지금 "${title}" 화면을 보고 있어. ` +
|
|
565
|
+
`카테고리: ${category}. ${cursor} ` +
|
|
566
|
+
`이 상황에 대해 짧고 재치있게 한마디 해줘. 20자 이내. 한국어로. ` +
|
|
567
|
+
`너는 데스크톱 위의 작은 귀여운 펫이야. 친근하고 장난스러운 톤으로.`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 타이틀 분석 기반 스마트 코멘트 생성
|
|
572
|
+
*
|
|
573
|
+
* AI API가 없어도 윈도우 제목에서 실제 맥락을 추출하여
|
|
574
|
+
* 프리셋보다 훨씬 자연스러운 코멘트를 생성한다.
|
|
575
|
+
*
|
|
576
|
+
* 예: "React hooks tutorial - YouTube" → "리액트 훅 공부하고 있구나!"
|
|
577
|
+
* "Pull Request #42 - GitHub" → "PR 리뷰 중? 꼼꼼히 봐!"
|
|
578
|
+
*/
|
|
579
|
+
function generateSmartBrowsingComment(ctx) {
|
|
580
|
+
const title = ctx.title || '';
|
|
581
|
+
const category = ctx.category || '';
|
|
582
|
+
const titleLower = title.toLowerCase();
|
|
583
|
+
|
|
584
|
+
// 타이틀에서 사이트명과 페이지 제목 분리
|
|
585
|
+
// 일반적 패턴: "페이지 제목 - 사이트명" or "사이트명: 페이지 제목"
|
|
586
|
+
const parts = title.split(/\s[-–|:]\s/);
|
|
587
|
+
const pageName = (parts[0] || title).trim();
|
|
588
|
+
const pageShort = pageName.slice(0, 20);
|
|
589
|
+
|
|
590
|
+
// 카테고리별 맥락 인식 코멘트 생성기
|
|
591
|
+
const generators = {
|
|
592
|
+
shopping: () => {
|
|
593
|
+
const templates = [
|
|
594
|
+
`${pageShort} 보고 있구나? 좋은 거 찾으면 알려줘!`,
|
|
595
|
+
`쇼핑 중이네! ${pageShort}... 살 거야?`,
|
|
596
|
+
`${pageShort} 괜찮아 보이는데? 장바구니 담을 거야?`,
|
|
597
|
+
];
|
|
598
|
+
return pick(templates);
|
|
599
|
+
},
|
|
600
|
+
video: () => {
|
|
601
|
+
if (titleLower.includes('youtube') || titleLower.includes('유튜브')) {
|
|
602
|
+
return `"${pageShort}" 재미있어? 나도 궁금!`;
|
|
603
|
+
}
|
|
604
|
+
if (titleLower.includes('netflix') || titleLower.includes('넷플릭스') ||
|
|
605
|
+
titleLower.includes('tving') || titleLower.includes('watcha')) {
|
|
606
|
+
return `뭐 보는 거야? "${pageShort}" 재밌어?`;
|
|
607
|
+
}
|
|
608
|
+
return `영상 보고 있구나! "${pageShort}" 추천할 만해?`;
|
|
609
|
+
},
|
|
610
|
+
sns: () => {
|
|
611
|
+
if (titleLower.includes('twitter') || titleLower.includes('x.com')) {
|
|
612
|
+
return '트윗 보고 있구나~ 재미있는 거 있어?';
|
|
613
|
+
}
|
|
614
|
+
if (titleLower.includes('instagram') || titleLower.includes('인스타')) {
|
|
615
|
+
return '인스타 구경 중? 좋은 사진 보여줘!';
|
|
616
|
+
}
|
|
617
|
+
if (titleLower.includes('reddit')) {
|
|
618
|
+
return '레딧 탐색 중! 어떤 서브레딧이야?';
|
|
619
|
+
}
|
|
620
|
+
return 'SNS 하고 있구나~ 무한 스크롤 조심!';
|
|
621
|
+
},
|
|
622
|
+
news: () => {
|
|
623
|
+
return `"${pageShort}" — 무슨 뉴스야? 좋은 소식이길!`;
|
|
624
|
+
},
|
|
625
|
+
dev: () => {
|
|
626
|
+
if (titleLower.includes('pull request') || titleLower.includes('pr #')) {
|
|
627
|
+
return 'PR 리뷰 중이구나! 꼼꼼히 봐~';
|
|
628
|
+
}
|
|
629
|
+
if (titleLower.includes('issue')) {
|
|
630
|
+
return '이슈 처리 중? 화이팅!';
|
|
631
|
+
}
|
|
632
|
+
if (titleLower.includes('stackoverflow') || titleLower.includes('stack overflow')) {
|
|
633
|
+
return '스택오버플로우! 뭐가 막혔어? 도와줄까?';
|
|
634
|
+
}
|
|
635
|
+
if (titleLower.includes('github')) {
|
|
636
|
+
return `GitHub에서 "${pageShort}" 작업 중?`;
|
|
637
|
+
}
|
|
638
|
+
if (titleLower.includes('docs') || titleLower.includes('documentation')) {
|
|
639
|
+
return '문서 읽고 있구나! 공부 열심히~';
|
|
640
|
+
}
|
|
641
|
+
return `코딩 관련! "${pageShort}" 화이팅!`;
|
|
642
|
+
},
|
|
643
|
+
search: () => {
|
|
644
|
+
// "검색어 - Google 검색" 패턴에서 검색어 추출
|
|
645
|
+
const searchMatch = title.match(/(.+?)\s*[-–]\s*(Google|Bing|네이버|Naver|검색)/i);
|
|
646
|
+
if (searchMatch) {
|
|
647
|
+
const query = searchMatch[1].trim().slice(0, 15);
|
|
648
|
+
const templates = [
|
|
649
|
+
`"${query}" 궁금해? 내가 알려줄 수도 있는데!`,
|
|
650
|
+
`"${query}" 검색하고 있구나~ 찾으면 알려줘!`,
|
|
651
|
+
`오, "${query}" 나도 궁금하다!`,
|
|
652
|
+
];
|
|
653
|
+
return pick(templates);
|
|
654
|
+
}
|
|
655
|
+
return '뭐 찾고 있어? 궁금한 거 있으면 물어봐!';
|
|
656
|
+
},
|
|
657
|
+
game: () => {
|
|
658
|
+
return `${pageShort} 하고 있어? 이기고 있어?!`;
|
|
659
|
+
},
|
|
660
|
+
music: () => {
|
|
661
|
+
return `뭐 듣고 있어? "${pageShort}" 좋은 노래야?`;
|
|
662
|
+
},
|
|
663
|
+
mail: () => {
|
|
664
|
+
return '메일 확인 중~ 중요한 거 있어?';
|
|
665
|
+
},
|
|
666
|
+
general: () => {
|
|
667
|
+
const templates = [
|
|
668
|
+
`"${pageShort}" 보고 있구나~`,
|
|
669
|
+
`오, ${pageShort}! 뭐 하는 거야?`,
|
|
670
|
+
];
|
|
671
|
+
return pick(templates);
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const gen = generators[category];
|
|
676
|
+
if (gen) return gen();
|
|
677
|
+
|
|
678
|
+
// 카테고리 미매칭: 제목 기반 일반 코멘트
|
|
679
|
+
if (pageName.length > 3) {
|
|
680
|
+
const templates = [
|
|
681
|
+
`"${pageShort}" 보고 있구나!`,
|
|
682
|
+
`오, ${pageShort}! 재미있어?`,
|
|
683
|
+
`${pageShort}... 뭐 하는 거야?`,
|
|
684
|
+
];
|
|
685
|
+
return pick(templates);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 배열에서 랜덤 선택 */
|
|
692
|
+
function pick(arr) {
|
|
693
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// =====================================================
|
|
697
|
+
// AI 캐릭터 생성 시스템
|
|
698
|
+
// 텔레그램 컨셉 설명 → AI가 16x16 픽셀 아트 생성
|
|
699
|
+
// =====================================================
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 캐릭터 생성 요청 처리 (텔레그램에서 트리거)
|
|
703
|
+
*
|
|
704
|
+
* 1차: apiRef로 AI 캐릭터 생성 (색상 + 프레임 데이터)
|
|
705
|
+
* 2차: 키워드 기반 색상 변환 (AI 없을 때 폴백)
|
|
706
|
+
*
|
|
707
|
+
* @param {object} event - { concept, chatId }
|
|
708
|
+
*/
|
|
709
|
+
async function handleCharacterRequest(event) {
|
|
710
|
+
if (!connector || !connector.connected) return;
|
|
711
|
+
|
|
712
|
+
const concept = event.concept || '';
|
|
713
|
+
if (!concept) return;
|
|
714
|
+
|
|
715
|
+
console.log(`[ClawMate] 캐릭터 생성 요청: "${concept}"`);
|
|
716
|
+
|
|
717
|
+
let characterData = null;
|
|
718
|
+
|
|
719
|
+
// 1차: AI로 색상 팔레트 + 프레임 데이터 생성
|
|
720
|
+
if (apiRef?.generate) {
|
|
721
|
+
try {
|
|
722
|
+
characterData = await generateCharacterWithAI(concept);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
console.log(`[ClawMate] AI 캐릭터 생성 실패: ${err.message}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 2차: AI chat으로 시도
|
|
729
|
+
if (!characterData && apiRef?.chat) {
|
|
730
|
+
try {
|
|
731
|
+
characterData = await generateCharacterWithChat(concept);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
console.log(`[ClawMate] AI chat 캐릭터 생성 실패: ${err.message}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 3차: 키워드 기반 색상만 변환 (폴백)
|
|
738
|
+
if (!characterData) {
|
|
739
|
+
characterData = generateCharacterFromKeywords(concept);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (characterData) {
|
|
743
|
+
// 캐릭터 데이터를 렌더러에 전송
|
|
744
|
+
connector._send('set_character', {
|
|
745
|
+
...characterData,
|
|
746
|
+
speech: `${concept} 변신 완료!`,
|
|
747
|
+
});
|
|
748
|
+
console.log(`[ClawMate] 캐릭터 생성 완료: "${concept}"`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* AI generate()로 캐릭터 생성
|
|
754
|
+
*/
|
|
755
|
+
async function generateCharacterWithAI(concept) {
|
|
756
|
+
const prompt = buildCharacterPrompt(concept);
|
|
757
|
+
const response = await apiRef.generate(prompt);
|
|
758
|
+
return parseCharacterResponse(response);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* AI chat()으로 캐릭터 생성
|
|
763
|
+
*/
|
|
764
|
+
async function generateCharacterWithChat(concept) {
|
|
765
|
+
const prompt = buildCharacterPrompt(concept);
|
|
766
|
+
const response = await apiRef.chat([
|
|
767
|
+
{ role: 'system', content: '넌 16x16 픽셀 아트 캐릭터 디자이너야. JSON으로 캐릭터 데이터를 출력해.' },
|
|
768
|
+
{ role: 'user', content: prompt },
|
|
769
|
+
]);
|
|
770
|
+
const text = response?.text || response?.content || response;
|
|
771
|
+
return parseCharacterResponse(text);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* 캐릭터 생성 프롬프트
|
|
776
|
+
*/
|
|
777
|
+
function buildCharacterPrompt(concept) {
|
|
778
|
+
return `"${concept}" 컨셉의 16x16 픽셀 아트 캐릭터를 만들어줘.
|
|
779
|
+
|
|
780
|
+
JSON 형식으로 출력해:
|
|
781
|
+
{
|
|
782
|
+
"colorMap": {
|
|
783
|
+
"primary": "#hex색상", // 메인 몸통 색
|
|
784
|
+
"secondary": "#hex색상", // 보조 색 (배, 볼 등)
|
|
785
|
+
"dark": "#hex색상", // 어두운 부분 (다리, 그림자)
|
|
786
|
+
"eye": "#hex색상", // 눈 흰자
|
|
787
|
+
"pupil": "#hex색상", // 눈동자
|
|
788
|
+
"claw": "#hex색상" // 집게/손/특징 부위
|
|
789
|
+
},
|
|
790
|
+
"frames": {
|
|
791
|
+
"idle": [
|
|
792
|
+
[16x16 숫자 배열 - frame 0],
|
|
793
|
+
[16x16 숫자 배열 - frame 1]
|
|
794
|
+
]
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
숫자 의미: 0=투명, 1=primary, 2=secondary, 3=dark, 4=eye, 5=pupil, 6=claw
|
|
799
|
+
캐릭터는 눈(4+5), 몸통(1+2), 다리(3), 특징(6)을 포함해야 해.
|
|
800
|
+
idle 프레임 2개만 만들어줘. 귀엽게!
|
|
801
|
+
JSON만 출력해.`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* AI 응답에서 캐릭터 데이터 파싱
|
|
806
|
+
*/
|
|
807
|
+
function parseCharacterResponse(response) {
|
|
808
|
+
if (!response || typeof response !== 'string') return null;
|
|
809
|
+
|
|
810
|
+
// JSON 블록 추출 (```json ... ``` 또는 { ... })
|
|
811
|
+
let jsonStr = response;
|
|
812
|
+
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
813
|
+
if (jsonMatch) {
|
|
814
|
+
jsonStr = jsonMatch[1].trim();
|
|
815
|
+
} else {
|
|
816
|
+
const braceMatch = response.match(/\{[\s\S]*\}/);
|
|
817
|
+
if (braceMatch) {
|
|
818
|
+
jsonStr = braceMatch[0];
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
const data = JSON.parse(jsonStr);
|
|
824
|
+
|
|
825
|
+
// colorMap 검증
|
|
826
|
+
if (data.colorMap) {
|
|
827
|
+
const required = ['primary', 'secondary', 'dark', 'eye', 'pupil', 'claw'];
|
|
828
|
+
for (const key of required) {
|
|
829
|
+
if (!data.colorMap[key]) return null;
|
|
830
|
+
}
|
|
831
|
+
} else {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// frames 검증 (있으면)
|
|
836
|
+
if (data.frames?.idle) {
|
|
837
|
+
for (const frame of data.frames.idle) {
|
|
838
|
+
if (!Array.isArray(frame) || frame.length !== 16) {
|
|
839
|
+
delete data.frames; // 프레임 데이터 불량 → 색상만 사용
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
for (const row of frame) {
|
|
843
|
+
if (!Array.isArray(row) || row.length !== 16) {
|
|
844
|
+
delete data.frames;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (!data.frames) break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return data;
|
|
853
|
+
} catch {
|
|
854
|
+
// JSON 파싱 실패 → 색상만 추출 시도
|
|
855
|
+
const colorMatch = response.match(/"primary"\s*:\s*"(#[0-9a-fA-F]{6})"/);
|
|
856
|
+
if (colorMatch) {
|
|
857
|
+
// 최소한 primary 색상이라도 추출
|
|
858
|
+
return generateCharacterFromKeywords(response);
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 키워드 기반 캐릭터 색상 생성 (AI 없을 때 폴백)
|
|
866
|
+
*
|
|
867
|
+
* 컨셉에서 색상/생물 키워드를 추출하여 팔레트 생성
|
|
868
|
+
*/
|
|
869
|
+
function generateCharacterFromKeywords(concept) {
|
|
870
|
+
const c = (concept || '').toLowerCase();
|
|
871
|
+
|
|
872
|
+
// 색상 키워드 매핑
|
|
873
|
+
const colorMap = {
|
|
874
|
+
'파란|파랑|blue': { primary: '#4488ff', secondary: '#6699ff', dark: '#223388', claw: '#4488ff' },
|
|
875
|
+
'초록|녹색|green': { primary: '#44cc44', secondary: '#66dd66', dark: '#226622', claw: '#44cc44' },
|
|
876
|
+
'보라|purple': { primary: '#8844cc', secondary: '#aa66dd', dark: '#442266', claw: '#8844cc' },
|
|
877
|
+
'노란|금색|yellow|gold': { primary: '#ffcc00', secondary: '#ffdd44', dark: '#886600', claw: '#ffcc00' },
|
|
878
|
+
'분홍|핑크|pink': { primary: '#ff69b4', secondary: '#ff8cc4', dark: '#8B3060', claw: '#ff69b4' },
|
|
879
|
+
'하얀|흰|white': { primary: '#eeeeee', secondary: '#ffffff', dark: '#999999', claw: '#dddddd' },
|
|
880
|
+
'검정|까만|black': { primary: '#333333', secondary: '#555555', dark: '#111111', claw: '#444444' },
|
|
881
|
+
'주황|orange': { primary: '#ff8800', secondary: '#ffaa33', dark: '#884400', claw: '#ff8800' },
|
|
882
|
+
'민트|틸|teal': { primary: '#00BFA5', secondary: '#33D4BC', dark: '#006655', claw: '#00BFA5' },
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// 생물 키워드 매핑
|
|
886
|
+
const creatureMap = {
|
|
887
|
+
'고양이|cat': { primary: '#ff9944', secondary: '#ffbb66', dark: '#663300', claw: '#ff9944' },
|
|
888
|
+
'로봇|robot': { primary: '#888888', secondary: '#aaaaaa', dark: '#444444', claw: '#66aaff' },
|
|
889
|
+
'슬라임|slime': { primary: '#44dd44', secondary: '#88ff88', dark: '#228822', claw: '#44dd44' },
|
|
890
|
+
'유령|ghost': { primary: '#ccccff', secondary: '#eeeeff', dark: '#6666aa', claw: '#ccccff' },
|
|
891
|
+
'드래곤|dragon': { primary: '#cc2222', secondary: '#ff4444', dark: '#661111', claw: '#ffaa00' },
|
|
892
|
+
'펭귄|penguin': { primary: '#222222', secondary: '#ffffff', dark: '#111111', claw: '#ff8800' },
|
|
893
|
+
'토끼|rabbit': { primary: '#ffcccc', secondary: '#ffeeee', dark: '#ff8888', claw: '#ffcccc' },
|
|
894
|
+
'악마|demon': { primary: '#660066', secondary: '#880088', dark: '#330033', claw: '#ff0000' },
|
|
895
|
+
'천사|angel': { primary: '#ffffff', secondary: '#ffffcc', dark: '#ddddaa', claw: '#ffdd00' },
|
|
896
|
+
'강아지|dog': { primary: '#cc8844', secondary: '#ddaa66', dark: '#664422', claw: '#cc8844' },
|
|
897
|
+
'불|fire': { primary: '#ff4400', secondary: '#ffaa00', dark: '#881100', claw: '#ff6600' },
|
|
898
|
+
'얼음|ice': { primary: '#88ccff', secondary: '#bbddff', dark: '#446688', claw: '#aaddff' },
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
// 색상 키워드 먼저 체크
|
|
902
|
+
for (const [keywords, palette] of Object.entries(colorMap)) {
|
|
903
|
+
for (const kw of keywords.split('|')) {
|
|
904
|
+
if (c.includes(kw)) {
|
|
905
|
+
return {
|
|
906
|
+
colorMap: { ...palette, eye: '#ffffff', pupil: '#111111' },
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 생물 키워드 체크
|
|
913
|
+
for (const [keywords, palette] of Object.entries(creatureMap)) {
|
|
914
|
+
for (const kw of keywords.split('|')) {
|
|
915
|
+
if (c.includes(kw)) {
|
|
916
|
+
return {
|
|
917
|
+
colorMap: { ...palette, eye: '#ffffff', pupil: '#111111' },
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 매칭 실패 → 랜덤 색상
|
|
924
|
+
const hue = Math.floor(Math.random() * 360);
|
|
925
|
+
const s = 70, l = 55;
|
|
926
|
+
return {
|
|
927
|
+
colorMap: {
|
|
928
|
+
primary: hslToHex(hue, s, l),
|
|
929
|
+
secondary: hslToHex(hue, s, l + 15),
|
|
930
|
+
dark: hslToHex(hue, s - 10, l - 30),
|
|
931
|
+
eye: '#ffffff',
|
|
932
|
+
pupil: '#111111',
|
|
933
|
+
claw: hslToHex(hue, s, l),
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** HSL → HEX 변환 */
|
|
939
|
+
function hslToHex(h, s, l) {
|
|
940
|
+
s /= 100;
|
|
941
|
+
l /= 100;
|
|
942
|
+
const a = s * Math.min(l, 1 - l);
|
|
943
|
+
const f = (n) => {
|
|
944
|
+
const k = (n + h / 30) % 12;
|
|
945
|
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
946
|
+
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
947
|
+
};
|
|
948
|
+
return `#${f(0)}${f(8)}${f(4)}`;
|
|
949
|
+
}
|
|
950
|
+
|
|
385
951
|
// =====================================================
|
|
386
952
|
// AI Think Loop — 주기적 자율 사고 시스템
|
|
387
953
|
// =====================================================
|
|
@@ -522,6 +1088,9 @@ async function thinkCycle() {
|
|
|
522
1088
|
|
|
523
1089
|
// --- 9) 바탕화면 폴더 나르기 (3분 간격, 10% 확률) ---
|
|
524
1090
|
handleFolderCarry(now);
|
|
1091
|
+
|
|
1092
|
+
// --- 10) AI 모션 생성 (2분 간격, 15% 확률) ---
|
|
1093
|
+
handleMotionGeneration(now, state);
|
|
525
1094
|
}
|
|
526
1095
|
|
|
527
1096
|
/**
|
|
@@ -574,7 +1143,7 @@ function handleTimeGreeting(now, hour, todayStr) {
|
|
|
574
1143
|
* 최소 30초 쿨타임, 야간에는 확률 대폭 감소
|
|
575
1144
|
*/
|
|
576
1145
|
function handleIdleSpeech(now, isNightMode) {
|
|
577
|
-
const speechCooldown = 30000; // 30
|
|
1146
|
+
const speechCooldown = 30000 * behaviorAdjustments.speechCooldownMultiplier; // 기본 30초, 메트릭에 의해 조절
|
|
578
1147
|
if (now - lastSpeechTime < speechCooldown) return;
|
|
579
1148
|
|
|
580
1149
|
// 야간: 5% 확률 / 주간: 25% 확률
|
|
@@ -592,7 +1161,7 @@ function handleIdleSpeech(now, isNightMode) {
|
|
|
592
1161
|
* 최소 5초 쿨타임, 가중치 기반 랜덤 선택
|
|
593
1162
|
*/
|
|
594
1163
|
function handleRandomAction(now, hour, isNightMode, state) {
|
|
595
|
-
const actionCooldown = 5000; // 5
|
|
1164
|
+
const actionCooldown = 5000 * behaviorAdjustments.actionCooldownMultiplier; // 기본 5초, 메트릭에 의해 조절
|
|
596
1165
|
if (now - lastActionTime < actionCooldown) return;
|
|
597
1166
|
|
|
598
1167
|
// 야간: 10% 확률 / 주간: 40% 확률
|
|
@@ -699,7 +1268,7 @@ function handleDesktopCheck(now) {
|
|
|
699
1268
|
|
|
700
1269
|
/**
|
|
701
1270
|
* 화면 관찰 (2분 간격, 10% 확률)
|
|
702
|
-
* 스크린샷을 캡처해서
|
|
1271
|
+
* 스크린샷을 캡처해서 AI가 화면 내용을 인식
|
|
703
1272
|
*/
|
|
704
1273
|
function handleScreenObservation(now) {
|
|
705
1274
|
const screenCheckInterval = 2 * 60 * 1000; // 2분
|
|
@@ -732,7 +1301,7 @@ function weightedRandom(items) {
|
|
|
732
1301
|
}
|
|
733
1302
|
|
|
734
1303
|
// =====================================================
|
|
735
|
-
// 공간 탐험 시스템 —
|
|
1304
|
+
// 공간 탐험 시스템 — 펫이 컴퓨터를 "집"처럼 돌아다님
|
|
736
1305
|
// =====================================================
|
|
737
1306
|
|
|
738
1307
|
/**
|
|
@@ -743,8 +1312,9 @@ function handleExploration(now, state) {
|
|
|
743
1312
|
const exploreInterval = 20000; // 20초
|
|
744
1313
|
if (now - lastExploreTime < exploreInterval) return;
|
|
745
1314
|
|
|
746
|
-
// 20% 확률
|
|
747
|
-
|
|
1315
|
+
// 기본 20% 확률 + explorationBias 보정 (bias가 양수면 탐험 확률 증가)
|
|
1316
|
+
const exploreChance = Math.max(0.05, Math.min(0.8, 0.2 + behaviorAdjustments.explorationBias));
|
|
1317
|
+
if (Math.random() > exploreChance) return;
|
|
748
1318
|
lastExploreTime = now;
|
|
749
1319
|
|
|
750
1320
|
// 가중치 기반 탐험 행동 선택
|
|
@@ -855,6 +1425,364 @@ function handleFolderCarry(now) {
|
|
|
855
1425
|
}
|
|
856
1426
|
}
|
|
857
1427
|
|
|
1428
|
+
// =====================================================
|
|
1429
|
+
// AI 모션 생성 시스템 — 키프레임 기반 움직임을 동적 생성
|
|
1430
|
+
// =====================================================
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* AI 모션 생성 처리 (2분 간격, 15% 확률)
|
|
1434
|
+
* 상황에 맞는 커스텀 이동 패턴을 AI가 직접 생성하여 등록+실행
|
|
1435
|
+
*
|
|
1436
|
+
* 생성 전략:
|
|
1437
|
+
* 1차: apiRef.generate()로 완전한 키프레임 데이터 생성
|
|
1438
|
+
* 2차: 상태 기반 프로시저럴 모션 생성 (폴백)
|
|
1439
|
+
*/
|
|
1440
|
+
async function handleMotionGeneration(now, state) {
|
|
1441
|
+
const motionGenInterval = 2 * 60 * 1000; // 2분
|
|
1442
|
+
if (now - lastMotionGenTime < motionGenInterval) return;
|
|
1443
|
+
if (Math.random() > 0.15) return; // 15% 확률
|
|
1444
|
+
lastMotionGenTime = now;
|
|
1445
|
+
|
|
1446
|
+
const currentState = state?.action || state?.state || 'idle';
|
|
1447
|
+
|
|
1448
|
+
// AI로 모션 생성 시도
|
|
1449
|
+
let motionDef = null;
|
|
1450
|
+
if (apiRef?.generate) {
|
|
1451
|
+
try {
|
|
1452
|
+
motionDef = await generateMotionWithAI(currentState);
|
|
1453
|
+
} catch {}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// 폴백: 프로시저럴 모션 생성
|
|
1457
|
+
if (!motionDef) {
|
|
1458
|
+
motionDef = generateProceduralMotion(currentState, now);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (motionDef && connector?.connected) {
|
|
1462
|
+
const motionName = `ai_motion_${generatedMotionCount++}`;
|
|
1463
|
+
connector.registerMovement(motionName, motionDef);
|
|
1464
|
+
|
|
1465
|
+
// 잠시 후 실행
|
|
1466
|
+
setTimeout(() => {
|
|
1467
|
+
if (connector?.connected) {
|
|
1468
|
+
connector.customMove(motionName, {});
|
|
1469
|
+
console.log(`[ClawMate] AI 모션 생성 실행: ${motionName}`);
|
|
1470
|
+
}
|
|
1471
|
+
}, 500);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* AI로 키프레임 모션 생성
|
|
1477
|
+
* 수학 공식(formula) 또는 웨이포인트(waypoints) 방식의 모션 정의를 생성
|
|
1478
|
+
*/
|
|
1479
|
+
async function generateMotionWithAI(currentState) {
|
|
1480
|
+
const prompt = `현재 펫 상태: ${currentState}.
|
|
1481
|
+
이 상황에 어울리는 재미있는 이동 패턴을 JSON으로 만들어줘.
|
|
1482
|
+
|
|
1483
|
+
두 가지 형식 중 하나를 선택:
|
|
1484
|
+
1) formula 방식 (수학적 궤도):
|
|
1485
|
+
{"type":"formula","formula":{"xAmp":80,"yAmp":40,"xFreq":1,"yFreq":2,"xPhase":0,"yPhase":0},"duration":3000,"speed":1.5}
|
|
1486
|
+
|
|
1487
|
+
2) waypoints 방식 (경로점):
|
|
1488
|
+
{"type":"waypoints","waypoints":[{"x":100,"y":200,"pause":300},{"x":300,"y":100},{"x":500,"y":250}],"speed":2}
|
|
1489
|
+
|
|
1490
|
+
규칙:
|
|
1491
|
+
- xAmp/yAmp: 10~150 사이 (화면 크기 고려)
|
|
1492
|
+
- duration: 2000~6000ms
|
|
1493
|
+
- waypoints: 3~6개
|
|
1494
|
+
- speed: 0.5~3
|
|
1495
|
+
- 펫 성격에 맞게: 장난스럽고 귀여운 움직임
|
|
1496
|
+
JSON만 출력해.`;
|
|
1497
|
+
|
|
1498
|
+
const response = await apiRef.generate(prompt);
|
|
1499
|
+
if (!response || typeof response !== 'string') return null;
|
|
1500
|
+
|
|
1501
|
+
// JSON 파싱
|
|
1502
|
+
let jsonStr = response;
|
|
1503
|
+
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1504
|
+
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
|
1505
|
+
else {
|
|
1506
|
+
const braceMatch = response.match(/\{[\s\S]*\}/);
|
|
1507
|
+
if (braceMatch) jsonStr = braceMatch[0];
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
try {
|
|
1511
|
+
const def = JSON.parse(jsonStr);
|
|
1512
|
+
// 기본 검증
|
|
1513
|
+
if (def.type === 'formula' && def.formula) {
|
|
1514
|
+
def.duration = Math.min(6000, Math.max(2000, def.duration || 3000));
|
|
1515
|
+
return def;
|
|
1516
|
+
}
|
|
1517
|
+
if (def.type === 'waypoints' && Array.isArray(def.waypoints) && def.waypoints.length >= 2) {
|
|
1518
|
+
return def;
|
|
1519
|
+
}
|
|
1520
|
+
} catch {}
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* 프로시저럴 모션 생성 (AI 없을 때 폴백)
|
|
1526
|
+
* 현재 상태와 시간에 따라 수학적으로 모션 패턴 생성
|
|
1527
|
+
*/
|
|
1528
|
+
function generateProceduralMotion(currentState, now) {
|
|
1529
|
+
const hour = new Date(now).getHours();
|
|
1530
|
+
const seed = now % 1000;
|
|
1531
|
+
|
|
1532
|
+
// 상태별 모션 특성
|
|
1533
|
+
const stateMotions = {
|
|
1534
|
+
idle: () => {
|
|
1535
|
+
// 가벼운 좌우 흔들림 또는 작은 원
|
|
1536
|
+
if (seed > 500) {
|
|
1537
|
+
return {
|
|
1538
|
+
type: 'formula',
|
|
1539
|
+
formula: { xAmp: 20 + seed % 30, yAmp: 5 + seed % 10, xFreq: 0.5, yFreq: 1, xPhase: 0, yPhase: Math.PI / 2 },
|
|
1540
|
+
duration: 3000,
|
|
1541
|
+
speed: 0.8,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
return {
|
|
1545
|
+
type: 'formula',
|
|
1546
|
+
formula: { xAmp: 15, yAmp: 15, xFreq: 1, yFreq: 1, xPhase: 0, yPhase: Math.PI / 2 },
|
|
1547
|
+
duration: 2500,
|
|
1548
|
+
speed: 0.6,
|
|
1549
|
+
};
|
|
1550
|
+
},
|
|
1551
|
+
walking: () => {
|
|
1552
|
+
// 지그재그 또는 사인파 이동
|
|
1553
|
+
const amp = 30 + seed % 50;
|
|
1554
|
+
return {
|
|
1555
|
+
type: 'formula',
|
|
1556
|
+
formula: { xAmp: amp, yAmp: amp * 0.3, xFreq: 0.5, yFreq: 2, xPhase: 0, yPhase: 0 },
|
|
1557
|
+
duration: 4000,
|
|
1558
|
+
speed: 1.2,
|
|
1559
|
+
};
|
|
1560
|
+
},
|
|
1561
|
+
excited: () => {
|
|
1562
|
+
// 활발한 8자 궤도
|
|
1563
|
+
return {
|
|
1564
|
+
type: 'formula',
|
|
1565
|
+
formula: { xAmp: 80 + seed % 40, yAmp: 40 + seed % 20, xFreq: 1, yFreq: 2, xPhase: 0, yPhase: 0 },
|
|
1566
|
+
duration: 3000,
|
|
1567
|
+
speed: 2.0,
|
|
1568
|
+
};
|
|
1569
|
+
},
|
|
1570
|
+
playing: () => {
|
|
1571
|
+
// 불규칙한 웨이포인트 (놀기 느낌)
|
|
1572
|
+
const points = [];
|
|
1573
|
+
for (let i = 0; i < 4; i++) {
|
|
1574
|
+
points.push({
|
|
1575
|
+
x: 100 + Math.floor(Math.random() * 800),
|
|
1576
|
+
y: 100 + Math.floor(Math.random() * 400),
|
|
1577
|
+
pause: i === 0 ? 200 : 0,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
return { type: 'waypoints', waypoints: points, speed: 2.5 };
|
|
1581
|
+
},
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
// 야간에는 느린 모션
|
|
1585
|
+
const isNight = hour >= 23 || hour < 6;
|
|
1586
|
+
const generator = stateMotions[currentState] || stateMotions.idle;
|
|
1587
|
+
const motion = generator();
|
|
1588
|
+
|
|
1589
|
+
if (isNight) {
|
|
1590
|
+
motion.speed = Math.min(0.5, (motion.speed || 1) * 0.4);
|
|
1591
|
+
if (motion.duration) motion.duration *= 1.5;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
return motion;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// =====================================================
|
|
1598
|
+
// 자기 관찰 시스템 (Metrics → 행동 조정)
|
|
1599
|
+
// =====================================================
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* 메트릭 데이터 수신 처리
|
|
1603
|
+
* 렌더러에서 30초마다 전송되는 동작 품질 메트릭을 분석하고,
|
|
1604
|
+
* 이상을 감지하여 행동 패턴을 자동 조정한다.
|
|
1605
|
+
*
|
|
1606
|
+
* @param {object} data - { metrics: {...}, timestamp }
|
|
1607
|
+
*/
|
|
1608
|
+
function handleMetrics(data) {
|
|
1609
|
+
if (!data || !data.metrics) return;
|
|
1610
|
+
const metrics = data.metrics;
|
|
1611
|
+
latestMetrics = metrics;
|
|
1612
|
+
|
|
1613
|
+
// 이력 유지 (최근 10개)
|
|
1614
|
+
metricsHistory.push(metrics);
|
|
1615
|
+
if (metricsHistory.length > 10) metricsHistory.shift();
|
|
1616
|
+
|
|
1617
|
+
// 이상 감지 및 반응
|
|
1618
|
+
_detectAnomalies(metrics);
|
|
1619
|
+
|
|
1620
|
+
// 행동 자동 조정
|
|
1621
|
+
adjustBehavior(metrics);
|
|
1622
|
+
|
|
1623
|
+
// 주기적 품질 보고서 (5분마다 콘솔 로그)
|
|
1624
|
+
const now = Date.now();
|
|
1625
|
+
if (now - lastMetricsLogTime >= 5 * 60 * 1000) {
|
|
1626
|
+
lastMetricsLogTime = now;
|
|
1627
|
+
_logQualityReport(metrics);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* 이상 감지: 메트릭 임계값을 초과하면 즉시 반응
|
|
1633
|
+
*
|
|
1634
|
+
* - FPS < 30 → 성능 경고, 행동 빈도 축소
|
|
1635
|
+
* - idle 비율 > 80% → 너무 멈춰있음, 활동 촉진
|
|
1636
|
+
* - 탐험 커버리지 < 30% → 새 영역 탐험 유도
|
|
1637
|
+
* - 사용자 클릭 0회 (장시간) → 관심 끌기 행동
|
|
1638
|
+
*/
|
|
1639
|
+
function _detectAnomalies(metrics) {
|
|
1640
|
+
if (!connector || !connector.connected) return;
|
|
1641
|
+
|
|
1642
|
+
// --- FPS 저하 감지 ---
|
|
1643
|
+
if (metrics.fps < 30 && metrics.fps > 0) {
|
|
1644
|
+
console.log(`[ClawMate][Metrics] FPS 저하 감지: ${metrics.fps}`);
|
|
1645
|
+
connector.speak('화면이 좀 버벅이네... 잠깐 쉴게.');
|
|
1646
|
+
connector.action('idle');
|
|
1647
|
+
|
|
1648
|
+
// 행동 빈도를 즉시 줄여 렌더링 부하 감소
|
|
1649
|
+
behaviorAdjustments.actionCooldownMultiplier = 3.0;
|
|
1650
|
+
behaviorAdjustments.speechCooldownMultiplier = 2.0;
|
|
1651
|
+
behaviorAdjustments.activityLevel = 0.5;
|
|
1652
|
+
return; // FPS 문제 시 다른 조정은 보류
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// --- idle 비율 과다 ---
|
|
1656
|
+
if (metrics.idleRatio > 0.8) {
|
|
1657
|
+
console.log(`[ClawMate][Metrics] idle 비율 과다: ${(metrics.idleRatio * 100).toFixed(0)}%`);
|
|
1658
|
+
|
|
1659
|
+
// 10% 확률로 각성 멘트 (매번 말하면 스팸)
|
|
1660
|
+
if (Math.random() < 0.1) {
|
|
1661
|
+
const idleReactions = [
|
|
1662
|
+
'가만히 있으면 재미없지! 좀 돌아다녀볼까~',
|
|
1663
|
+
'멍때리고 있었네... 움직여야지!',
|
|
1664
|
+
'심심해~ 탐험 가자!',
|
|
1665
|
+
];
|
|
1666
|
+
const text = idleReactions[Math.floor(Math.random() * idleReactions.length)];
|
|
1667
|
+
connector.speak(text);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// --- 탐험 커버리지 부족 ---
|
|
1672
|
+
if (metrics.explorationCoverage < 0.3 && metrics.period >= 25000) {
|
|
1673
|
+
console.log(`[ClawMate][Metrics] 탐험 커버리지 부족: ${(metrics.explorationCoverage * 100).toFixed(0)}%`);
|
|
1674
|
+
|
|
1675
|
+
// 5% 확률로 탐험 유도 (빈도 조절)
|
|
1676
|
+
if (Math.random() < 0.05) {
|
|
1677
|
+
connector.speak('아직 안 가본 곳이 많네~ 탐험해볼까!');
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// --- 사용자 상호작용 감소 ---
|
|
1682
|
+
// 최근 3개 보고에서 연속으로 클릭 0회이면 관심 끌기
|
|
1683
|
+
if (metricsHistory.length >= 3) {
|
|
1684
|
+
const recent3 = metricsHistory.slice(-3);
|
|
1685
|
+
const noClicks = recent3.every(m => (m.userClicks || 0) === 0);
|
|
1686
|
+
if (noClicks) {
|
|
1687
|
+
// 5% 확률로 관심 끌기 (연속 감지 시)
|
|
1688
|
+
if (Math.random() < 0.05) {
|
|
1689
|
+
connector.decide({
|
|
1690
|
+
action: 'excited',
|
|
1691
|
+
speech: '나 여기 있어~ 심심하면 클릭해줘!',
|
|
1692
|
+
emotion: 'playful',
|
|
1693
|
+
});
|
|
1694
|
+
console.log('[ClawMate][Metrics] 사용자 상호작용 감소 → 관심 끌기');
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* 행동 패턴 자동 조정
|
|
1702
|
+
* 메트릭 데이터를 기반으로 행동 빈도/패턴을 실시간 튜닝한다.
|
|
1703
|
+
*
|
|
1704
|
+
* 조정 원칙:
|
|
1705
|
+
* - FPS가 낮으면 행동 빈도를 줄여 렌더링 부하 감소
|
|
1706
|
+
* - idle이 너무 많으면 행동을 활발하게
|
|
1707
|
+
* - 탐험 커버리지가 낮으면 탐험 확률 증가
|
|
1708
|
+
* - 사용자 상호작용이 활발하면 대응 빈도 증가
|
|
1709
|
+
*
|
|
1710
|
+
* @param {object} metrics - 현재 메트릭 데이터
|
|
1711
|
+
*/
|
|
1712
|
+
function adjustBehavior(metrics) {
|
|
1713
|
+
// --- FPS 기반 활동 수준 조절 ---
|
|
1714
|
+
if (metrics.fps >= 50) {
|
|
1715
|
+
// 충분한 성능 → 정상 활동
|
|
1716
|
+
behaviorAdjustments.activityLevel = 1.0;
|
|
1717
|
+
behaviorAdjustments.actionCooldownMultiplier = 1.0;
|
|
1718
|
+
} else if (metrics.fps >= 30) {
|
|
1719
|
+
// 성능 약간 부족 → 활동 약간 축소
|
|
1720
|
+
behaviorAdjustments.activityLevel = 0.8;
|
|
1721
|
+
behaviorAdjustments.actionCooldownMultiplier = 1.5;
|
|
1722
|
+
} else {
|
|
1723
|
+
// 성능 부족 → 활동 대폭 축소 (_detectAnomalies에서 이미 처리)
|
|
1724
|
+
behaviorAdjustments.activityLevel = 0.5;
|
|
1725
|
+
behaviorAdjustments.actionCooldownMultiplier = 3.0;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// --- idle 비율 기반 활동 조절 ---
|
|
1729
|
+
if (metrics.idleRatio > 0.8) {
|
|
1730
|
+
// 너무 멈춰있음 → 행동 쿨타임 단축, 활동 수준 증가
|
|
1731
|
+
behaviorAdjustments.actionCooldownMultiplier = Math.max(0.5,
|
|
1732
|
+
behaviorAdjustments.actionCooldownMultiplier * 0.7);
|
|
1733
|
+
behaviorAdjustments.activityLevel = Math.min(1.5,
|
|
1734
|
+
behaviorAdjustments.activityLevel * 1.3);
|
|
1735
|
+
} else if (metrics.idleRatio < 0.1) {
|
|
1736
|
+
// 너무 바쁨 → 약간 쉬게
|
|
1737
|
+
behaviorAdjustments.actionCooldownMultiplier = Math.max(1.0,
|
|
1738
|
+
behaviorAdjustments.actionCooldownMultiplier * 1.2);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// --- 탐험 커버리지 기반 탐험 편향 ---
|
|
1742
|
+
if (metrics.explorationCoverage < 0.3) {
|
|
1743
|
+
// 탐험 부족 → 탐험 확률 증가
|
|
1744
|
+
behaviorAdjustments.explorationBias = 0.15;
|
|
1745
|
+
} else if (metrics.explorationCoverage > 0.7) {
|
|
1746
|
+
// 충분히 탐험함 → 탐험 확률 기본으로
|
|
1747
|
+
behaviorAdjustments.explorationBias = 0;
|
|
1748
|
+
} else {
|
|
1749
|
+
// 중간 → 약간 증가
|
|
1750
|
+
behaviorAdjustments.explorationBias = 0.05;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// --- 사용자 상호작용 기반 말풍선 빈도 ---
|
|
1754
|
+
if (metrics.userClicks > 3) {
|
|
1755
|
+
// 사용자가 활발히 클릭 → 말풍선 빈도 증가 (반응적)
|
|
1756
|
+
behaviorAdjustments.speechCooldownMultiplier = 0.7;
|
|
1757
|
+
} else if (metrics.userClicks === 0 && metrics.speechCount > 5) {
|
|
1758
|
+
// 사용자 무반응인데 말이 많음 → 말풍선 줄이기
|
|
1759
|
+
behaviorAdjustments.speechCooldownMultiplier = 1.5;
|
|
1760
|
+
} else {
|
|
1761
|
+
behaviorAdjustments.speechCooldownMultiplier = 1.0;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// 값 범위 클램핑 (안전 장치)
|
|
1765
|
+
behaviorAdjustments.activityLevel = Math.max(0.3, Math.min(2.0, behaviorAdjustments.activityLevel));
|
|
1766
|
+
behaviorAdjustments.actionCooldownMultiplier = Math.max(0.3, Math.min(5.0, behaviorAdjustments.actionCooldownMultiplier));
|
|
1767
|
+
behaviorAdjustments.speechCooldownMultiplier = Math.max(0.3, Math.min(5.0, behaviorAdjustments.speechCooldownMultiplier));
|
|
1768
|
+
behaviorAdjustments.explorationBias = Math.max(-0.15, Math.min(0.3, behaviorAdjustments.explorationBias));
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* 품질 보고서 콘솔 출력 (5분마다)
|
|
1773
|
+
* 개발자/운영자가 펫의 동작 품질을 모니터링할 수 있도록 한다.
|
|
1774
|
+
*/
|
|
1775
|
+
function _logQualityReport(metrics) {
|
|
1776
|
+
const adj = behaviorAdjustments;
|
|
1777
|
+
console.log('=== [ClawMate] 동작 품질 보고서 ===');
|
|
1778
|
+
console.log(` FPS: ${metrics.fps} | 프레임 일관성: ${metrics.animationFrameConsistency}`);
|
|
1779
|
+
console.log(` 이동 부드러움: ${metrics.movementSmoothness} | 벽면 밀착: ${metrics.wallContactAccuracy}`);
|
|
1780
|
+
console.log(` idle 비율: ${(metrics.idleRatio * 100).toFixed(0)}% | 탐험 커버리지: ${(metrics.explorationCoverage * 100).toFixed(0)}%`);
|
|
1781
|
+
console.log(` 응답 시간: ${metrics.interactionResponseMs}ms | 말풍선: ${metrics.speechCount}회 | 클릭: ${metrics.userClicks}회`);
|
|
1782
|
+
console.log(` [조정] 활동수준: ${adj.activityLevel.toFixed(2)} | 행동쿨타임: x${adj.actionCooldownMultiplier.toFixed(2)} | 말풍선쿨타임: x${adj.speechCooldownMultiplier.toFixed(2)} | 탐험편향: ${adj.explorationBias.toFixed(2)}`);
|
|
1783
|
+
console.log('====================================');
|
|
1784
|
+
}
|
|
1785
|
+
|
|
858
1786
|
// =====================================================
|
|
859
1787
|
// npm 패키지 버전 체크 (npm install -g 사용자용)
|
|
860
1788
|
// =====================================================
|