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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI 행동 컨트롤러
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* AI가 연결되면 → AI가 모든 행동을 결정
|
|
5
|
+
* AI가 끊기면 → 자율 모드 (기존 FSM) 로 폴백
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* AI가 결정하는 것:
|
|
8
8
|
* - 언제 뭐라고 말할지
|
|
9
9
|
* - 어디로 움직일지
|
|
10
10
|
* - 어떤 감정을 표현할지
|
|
@@ -32,7 +32,7 @@ const AIController = (() => {
|
|
|
32
32
|
window.clawmate.onAIConnected(() => {
|
|
33
33
|
connected = true;
|
|
34
34
|
autonomousMode = false;
|
|
35
|
-
Speech.show('
|
|
35
|
+
Speech.show('AI 연결됨... 의식이 깨어난다.');
|
|
36
36
|
StateMachine.forceState('excited');
|
|
37
37
|
});
|
|
38
38
|
}
|
|
@@ -47,7 +47,7 @@ const AIController = (() => {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* AI로부터 온 명령 실행
|
|
51
51
|
*/
|
|
52
52
|
function handleAICommand(command) {
|
|
53
53
|
const { type, payload } = command;
|
|
@@ -140,12 +140,317 @@ const AIController = (() => {
|
|
|
140
140
|
// payload: { windowId, x, y }
|
|
141
141
|
PetEngine.jumpTo(payload.x, payload.y);
|
|
142
142
|
break;
|
|
143
|
+
|
|
144
|
+
// === 커스텀 이동 패턴 ===
|
|
145
|
+
|
|
146
|
+
case 'register_movement':
|
|
147
|
+
// AI가 JSON으로 이동 패턴 정의를 보내면 등록
|
|
148
|
+
// payload: { name, definition }
|
|
149
|
+
// definition: { type, params } — 각 타입별 파라미터
|
|
150
|
+
_registerAIMovement(payload.name, payload.definition);
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case 'custom_move':
|
|
154
|
+
// 등록된 커스텀 이동 패턴 실행
|
|
155
|
+
// payload: { name, params? }
|
|
156
|
+
if (!PetEngine.executeCustomMovement(payload.name, payload.params || {})) {
|
|
157
|
+
// 실행 실패 시 AI에 알림
|
|
158
|
+
if (window.clawmate.reportToAI) {
|
|
159
|
+
window.clawmate.reportToAI('custom_move_failed', {
|
|
160
|
+
name: payload.name,
|
|
161
|
+
available: PetEngine.getRegisteredMovements(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'stop_custom_move':
|
|
168
|
+
// 현재 커스텀 이동 강제 중지
|
|
169
|
+
PetEngine.stopCustomMovement();
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'list_movements':
|
|
173
|
+
// 등록된 이동 패턴 목록 요청
|
|
174
|
+
if (window.clawmate.reportToAI) {
|
|
175
|
+
window.clawmate.reportToAI('movement_list', {
|
|
176
|
+
movements: PetEngine.getRegisteredMovements(),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
// === 캐릭터 커스터마이징 ===
|
|
182
|
+
case 'set_character':
|
|
183
|
+
// AI가 생성한 새 캐릭터 데이터 적용
|
|
184
|
+
Character.setCharacterData(payload);
|
|
185
|
+
if (payload.speech) {
|
|
186
|
+
Speech.show(payload.speech);
|
|
187
|
+
} else {
|
|
188
|
+
Speech.show('변신 완료!');
|
|
189
|
+
}
|
|
190
|
+
StateMachine.forceState('excited');
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
if (StateMachine.getState() === 'excited') StateMachine.forceState('idle');
|
|
193
|
+
}, 2000);
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'reset_character':
|
|
197
|
+
// 원래 캐릭터로 복원
|
|
198
|
+
Character.resetCharacter();
|
|
199
|
+
Speech.show('원래 모습으로 돌아왔어!');
|
|
200
|
+
StateMachine.forceState('excited');
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
// === 인격체 전환 (Incarnation 모드) ===
|
|
204
|
+
case 'set_persona':
|
|
205
|
+
// 봇 인격체 데이터 적용
|
|
206
|
+
if (typeof ModeManager !== 'undefined') {
|
|
207
|
+
ModeManager.setPersona(payload);
|
|
208
|
+
const name = payload.name || 'Claw';
|
|
209
|
+
Speech.show(`${name}의 인격이 깨어났다.`);
|
|
210
|
+
StateMachine.forceState('excited');
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
// === 스마트 파일 조작 애니메이션 ===
|
|
215
|
+
case 'smart_file_op':
|
|
216
|
+
handleSmartFileOp(payload);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 스마트 파일 조작 애니메이션 처리
|
|
223
|
+
*
|
|
224
|
+
* 텔레그램이나 AI에서 트리거된 파일 이동 작업의
|
|
225
|
+
* 각 단계(phase)에 따라 펫 애니메이션을 순차 실행.
|
|
226
|
+
*
|
|
227
|
+
* phase:
|
|
228
|
+
* - start: 작업 시작, 총 파일 수 표시
|
|
229
|
+
* - pick_up: 파일 집어들기 (carrying 상태 + 말풍선)
|
|
230
|
+
* - drop: 파일 내려놓기 (걷기 상태 + 말풍선)
|
|
231
|
+
* - complete: 완료 (excited 상태 + 결과 말풍선)
|
|
232
|
+
* - error: 오류 (scared 상태 + 에러 말풍선)
|
|
233
|
+
*/
|
|
234
|
+
function handleSmartFileOp(payload) {
|
|
235
|
+
switch (payload.phase) {
|
|
236
|
+
case 'start':
|
|
237
|
+
StateMachine.forceState('excited');
|
|
238
|
+
Speech.show(`${payload.totalFiles}개 파일 정리 시작!`);
|
|
239
|
+
break;
|
|
240
|
+
|
|
241
|
+
case 'pick_up':
|
|
242
|
+
// 펫이 파일 위치로 이동 (화면 내 랜덤 위치)
|
|
243
|
+
_smartFileJumpToSource(payload.index);
|
|
244
|
+
// 집어들기 애니메이션
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
StateMachine.forceState('carrying');
|
|
247
|
+
Speech.show(`${payload.fileName} 집었다!`);
|
|
248
|
+
}, 400);
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case 'drop':
|
|
252
|
+
// 대상 폴더 위치로 이동
|
|
253
|
+
_smartFileJumpToTarget(payload.index);
|
|
254
|
+
// 내려놓기 애니메이션
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
StateMachine.forceState('walking');
|
|
257
|
+
Speech.show(`여기! (${payload.targetName})`);
|
|
258
|
+
}, 400);
|
|
259
|
+
break;
|
|
260
|
+
|
|
261
|
+
case 'complete':
|
|
262
|
+
StateMachine.forceState('excited');
|
|
263
|
+
if (payload.movedCount > 0) {
|
|
264
|
+
Speech.show(`${payload.movedCount}개 파일 옮겼어!`);
|
|
265
|
+
} else {
|
|
266
|
+
Speech.show('옮길 파일이 없었어!');
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case 'error':
|
|
271
|
+
StateMachine.forceState('scared');
|
|
272
|
+
Speech.show('앗, 뭔가 잘못됐어...');
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 파일 집어들기 위치로 점프
|
|
279
|
+
* 파일 인덱스에 따라 화면 좌측 영역의 다른 위치로 이동
|
|
280
|
+
*/
|
|
281
|
+
function _smartFileJumpToSource(index) {
|
|
282
|
+
const screenW = window.innerWidth;
|
|
283
|
+
const screenH = window.innerHeight;
|
|
284
|
+
// 화면 왼쪽 1/3 영역에서 세로 위치를 파일 인덱스에 따라 분산
|
|
285
|
+
const targetX = screenW * 0.1 + (index % 3) * 50;
|
|
286
|
+
const targetY = screenH * 0.3 + ((index * 80) % (screenH * 0.5));
|
|
287
|
+
PetEngine.jumpTo(targetX, targetY);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 파일 내려놓기 위치로 점프
|
|
292
|
+
* 화면 오른쪽 영역으로 이동
|
|
293
|
+
*/
|
|
294
|
+
function _smartFileJumpToTarget(index) {
|
|
295
|
+
const screenW = window.innerWidth;
|
|
296
|
+
const screenH = window.innerHeight;
|
|
297
|
+
// 화면 오른쪽 1/3 영역
|
|
298
|
+
const targetX = screenW * 0.7 + (index % 3) * 50;
|
|
299
|
+
const targetY = screenH * 0.4 + ((index * 60) % (screenH * 0.4));
|
|
300
|
+
PetEngine.jumpTo(targetX, targetY);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* AI가 JSON으로 정의한 이동 패턴을 동적으로 등록
|
|
305
|
+
* 안전한 실행을 위해 Function 생성자 대신 사전정의된 행동 유형 조합 사용
|
|
306
|
+
*
|
|
307
|
+
* definition 형식:
|
|
308
|
+
* {
|
|
309
|
+
* type: 'waypoints' | 'formula' | 'sequence',
|
|
310
|
+
* waypoints?: [{x, y, pause?}], // waypoints 타입
|
|
311
|
+
* formula?: { xExpr, yExpr }, // formula 타입 (sin, cos 기반)
|
|
312
|
+
* sequence?: ['zigzag', 'shake', ...], // sequence 타입 (기존 패턴 순차 실행)
|
|
313
|
+
* duration?: number,
|
|
314
|
+
* speed?: number,
|
|
315
|
+
* }
|
|
316
|
+
*/
|
|
317
|
+
function _registerAIMovement(name, definition) {
|
|
318
|
+
if (!name || !definition || !definition.type) {
|
|
319
|
+
console.warn('[AIController] 이동 패턴 등록 실패: name, definition.type 필수');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let handler;
|
|
324
|
+
|
|
325
|
+
switch (definition.type) {
|
|
326
|
+
// 웨이포인트 타입: 지정된 좌표들을 순서대로 이동
|
|
327
|
+
case 'waypoints':
|
|
328
|
+
handler = {
|
|
329
|
+
init(params) {
|
|
330
|
+
return {
|
|
331
|
+
waypoints: definition.waypoints || [],
|
|
332
|
+
currentIdx: 0,
|
|
333
|
+
speed: definition.speed || 2,
|
|
334
|
+
pauseTime: 0,
|
|
335
|
+
pausing: false,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
update(dt, state, ctx) {
|
|
339
|
+
if (state.currentIdx >= state.waypoints.length) return;
|
|
340
|
+
|
|
341
|
+
const wp = state.waypoints[state.currentIdx];
|
|
342
|
+
|
|
343
|
+
// 웨이포인트에서 멈춤 중
|
|
344
|
+
if (state.pausing) {
|
|
345
|
+
state.pauseTime -= dt;
|
|
346
|
+
if (state.pauseTime <= 0) {
|
|
347
|
+
state.pausing = false;
|
|
348
|
+
state.currentIdx++;
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const dx = wp.x - ctx.x;
|
|
354
|
+
const dy = wp.y - ctx.y;
|
|
355
|
+
const dist = Math.hypot(dx, dy);
|
|
356
|
+
|
|
357
|
+
if (dist < 5) {
|
|
358
|
+
// 웨이포인트 도달
|
|
359
|
+
if (wp.pause && wp.pause > 0) {
|
|
360
|
+
state.pausing = true;
|
|
361
|
+
state.pauseTime = wp.pause;
|
|
362
|
+
} else {
|
|
363
|
+
state.currentIdx++;
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const step = state.speed * (dt / 16);
|
|
369
|
+
const ratio = Math.min(1, step / dist);
|
|
370
|
+
ctx.setPos(ctx.x + dx * ratio, ctx.y + dy * ratio);
|
|
371
|
+
ctx.setFlip(dx < 0);
|
|
372
|
+
},
|
|
373
|
+
isComplete(state) {
|
|
374
|
+
return state.currentIdx >= (state.waypoints || []).length;
|
|
375
|
+
},
|
|
376
|
+
cleanup() {},
|
|
377
|
+
};
|
|
378
|
+
break;
|
|
379
|
+
|
|
380
|
+
// 수식 타입: sin/cos 기반 수학적 궤도
|
|
381
|
+
case 'formula':
|
|
382
|
+
handler = {
|
|
383
|
+
init(params) {
|
|
384
|
+
return {
|
|
385
|
+
duration: definition.duration || 3000,
|
|
386
|
+
elapsed: 0,
|
|
387
|
+
originX: params.x,
|
|
388
|
+
originY: params.y,
|
|
389
|
+
xAmp: definition.formula?.xAmp || 50,
|
|
390
|
+
yAmp: definition.formula?.yAmp || 30,
|
|
391
|
+
xFreq: definition.formula?.xFreq || 1,
|
|
392
|
+
yFreq: definition.formula?.yFreq || 1,
|
|
393
|
+
xPhase: definition.formula?.xPhase || 0,
|
|
394
|
+
yPhase: definition.formula?.yPhase || 0,
|
|
395
|
+
};
|
|
396
|
+
},
|
|
397
|
+
update(dt, state, ctx) {
|
|
398
|
+
state.elapsed += dt;
|
|
399
|
+
const t = (state.elapsed / state.duration) * Math.PI * 2;
|
|
400
|
+
const nx = state.originX + Math.sin(t * state.xFreq + state.xPhase) * state.xAmp;
|
|
401
|
+
const ny = state.originY + Math.sin(t * state.yFreq + state.yPhase) * state.yAmp;
|
|
402
|
+
ctx.setPos(nx, ny);
|
|
403
|
+
ctx.setFlip(Math.cos(t * state.xFreq + state.xPhase) < 0);
|
|
404
|
+
},
|
|
405
|
+
isComplete(state) {
|
|
406
|
+
return state.elapsed >= state.duration;
|
|
407
|
+
},
|
|
408
|
+
cleanup() {},
|
|
409
|
+
};
|
|
410
|
+
break;
|
|
411
|
+
|
|
412
|
+
// 시퀀스 타입: 기존 등록된 패턴들을 순차 실행
|
|
413
|
+
case 'sequence':
|
|
414
|
+
handler = {
|
|
415
|
+
init(params) {
|
|
416
|
+
return {
|
|
417
|
+
sequence: definition.sequence || [],
|
|
418
|
+
currentIdx: 0,
|
|
419
|
+
subStarted: false,
|
|
420
|
+
};
|
|
421
|
+
},
|
|
422
|
+
update(dt, state, ctx) {
|
|
423
|
+
if (state.currentIdx >= state.sequence.length) return;
|
|
424
|
+
|
|
425
|
+
if (!state.subStarted) {
|
|
426
|
+
const subName = state.sequence[state.currentIdx];
|
|
427
|
+
// 서브 패턴을 직접 실행하지 않고 상태만 추적
|
|
428
|
+
PetEngine.executeCustomMovement(subName, {
|
|
429
|
+
x: ctx.x, y: ctx.y,
|
|
430
|
+
screenW: ctx.screenW, screenH: ctx.screenH,
|
|
431
|
+
});
|
|
432
|
+
state.subStarted = true;
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
isComplete(state) {
|
|
436
|
+
return state.currentIdx >= (state.sequence || []).length;
|
|
437
|
+
},
|
|
438
|
+
cleanup() {},
|
|
439
|
+
};
|
|
440
|
+
break;
|
|
441
|
+
|
|
442
|
+
default:
|
|
443
|
+
console.warn(`[AIController] 알 수 없는 이동 패턴 타입: ${definition.type}`);
|
|
444
|
+
return;
|
|
143
445
|
}
|
|
446
|
+
|
|
447
|
+
PetEngine.registerMovement(name, handler);
|
|
448
|
+
console.log(`[AIController] AI 이동 패턴 등록됨: ${name} (${definition.type})`);
|
|
144
449
|
}
|
|
145
450
|
|
|
146
451
|
/**
|
|
147
452
|
* AI 종합 의사결정 실행
|
|
148
|
-
*
|
|
453
|
+
* AI가 상황을 분석하고 내린 복합적 결정
|
|
149
454
|
*
|
|
150
455
|
* 예시:
|
|
151
456
|
* {
|
|
@@ -202,7 +507,7 @@ const AIController = (() => {
|
|
|
202
507
|
StateMachine.forceState(state);
|
|
203
508
|
}
|
|
204
509
|
|
|
205
|
-
// === 사용자 이벤트 →
|
|
510
|
+
// === 사용자 이벤트 → AI에 리포트 ===
|
|
206
511
|
|
|
207
512
|
function reportClick(position) {
|
|
208
513
|
if (window.clawmate.reportToAI) {
|
package/renderer/js/app.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* ClawMate 렌더러 초기화
|
|
3
3
|
*
|
|
4
4
|
* 아키텍처:
|
|
5
|
-
*
|
|
5
|
+
* AI (뇌) ←→ AI Bridge (WebSocket) ←→ AI Controller (렌더러)
|
|
6
6
|
* ↓
|
|
7
7
|
* StateMachine / PetEngine / Speech
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* AI 연결 시: AI가 모든 행동/말/감정 결정
|
|
10
|
+
* AI 미연결 시: 자율 모드 (FSM 기반) 로 혼자 놀기
|
|
11
11
|
*/
|
|
12
12
|
(async function initClawMate() {
|
|
13
13
|
const petContainer = document.getElementById('pet-container');
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
Interactions.spawnStarEffect();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
48
|
+
// 모션 히스토리 기록
|
|
49
|
+
Memory.recordMotion(newState);
|
|
50
|
+
|
|
51
|
+
// 상태 변화를 AI에 리포트
|
|
49
52
|
if (window.clawmate.reportToAI) {
|
|
50
53
|
window.clawmate.reportToAI('state_change', {
|
|
51
54
|
from: prevState, to: newState,
|
|
@@ -62,7 +65,7 @@
|
|
|
62
65
|
// 메모리 초기화 (진화 상태 포함)
|
|
63
66
|
await Memory.init();
|
|
64
67
|
|
|
65
|
-
// AI 컨트롤러 초기화 (
|
|
68
|
+
// AI 컨트롤러 초기화 (AI 연결 관리)
|
|
66
69
|
AIController.init();
|
|
67
70
|
|
|
68
71
|
// 상호작용 초기화
|
|
@@ -71,6 +74,16 @@
|
|
|
71
74
|
// 시간 인식 초기화 (자율 모드에서만 주도적으로 동작)
|
|
72
75
|
TimeAware.init();
|
|
73
76
|
|
|
77
|
+
// 메트릭 수집기 초기화 (선택적 — 없어도 앱 정상 동작)
|
|
78
|
+
if (typeof Metrics !== 'undefined') {
|
|
79
|
+
Metrics.init();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 브라우저 감시 초기화 (참견쟁이 모드)
|
|
83
|
+
if (typeof BrowserWatcher !== 'undefined') {
|
|
84
|
+
BrowserWatcher.init();
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
// 엔진 시작
|
|
75
88
|
PetEngine.start();
|
|
76
89
|
|
|
@@ -82,7 +95,7 @@
|
|
|
82
95
|
// AI 연결 상태 표시
|
|
83
96
|
const connected = await window.clawmate.isAIConnected();
|
|
84
97
|
if (connected) {
|
|
85
|
-
Speech.show('
|
|
98
|
+
Speech.show('AI와 연결됨. 지시를 기다리는 중...');
|
|
86
99
|
} else {
|
|
87
100
|
Speech.show('안녕! 나 혼자서도 잘 놀 수 있어!');
|
|
88
101
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 브라우저 활동 감시 + AI 코멘트 시스템
|
|
3
|
+
*
|
|
4
|
+
* 두 가지 모드:
|
|
5
|
+
* AI 연결 시: 윈도우 제목 + 화면 캡처 + 커서 위치를 AI에 전송 → AI가 맥락 있는 코멘트 생성
|
|
6
|
+
* AI 미연결 시: 프리셋 메시지로 폴백 (자율 모드)
|
|
7
|
+
*
|
|
8
|
+
* 동작:
|
|
9
|
+
* 1. 15초마다 활성 윈도우 제목 + 커서 위치 조회
|
|
10
|
+
* 2. 브라우저/앱 감지 시 AI에 컨텍스트 리포트 (제목 + 화면 캡처)
|
|
11
|
+
* 3. AI가 제목/캡처를 분석해서 상황 맞는 코멘트 생성
|
|
12
|
+
* 4. 자율 모드에서는 프리셋 메시지 폴백
|
|
13
|
+
*/
|
|
14
|
+
const BrowserWatcher = (() => {
|
|
15
|
+
const CHECK_INTERVAL = 15000; // 활성 윈도우 체크 주기 (15초)
|
|
16
|
+
const AI_COOLDOWN = 45000; // AI 코멘트 쿨다운 (45초)
|
|
17
|
+
const FALLBACK_COOLDOWN = 90000; // 자율 모드 코멘트 쿨다운 (90초)
|
|
18
|
+
const COMMENT_CHANCE = 0.4; // 코멘트 확률 (40%)
|
|
19
|
+
const SITE_CHANGE_BONUS = 0.3; // 사이트 변경 시 추가 확률
|
|
20
|
+
|
|
21
|
+
let intervalId = null;
|
|
22
|
+
let lastCategory = null;
|
|
23
|
+
let lastCommentTime = 0;
|
|
24
|
+
let lastTitle = '';
|
|
25
|
+
let enabled = true;
|
|
26
|
+
|
|
27
|
+
function init() {
|
|
28
|
+
intervalId = setInterval(check, CHECK_INTERVAL);
|
|
29
|
+
// 첫 체크는 10초 후 (앱 시작 직후는 건너뜀)
|
|
30
|
+
setTimeout(check, 10000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function check() {
|
|
34
|
+
if (!enabled) return;
|
|
35
|
+
if (typeof Speech === 'undefined') return;
|
|
36
|
+
|
|
37
|
+
// sleeping 상태면 참견 안 함
|
|
38
|
+
if (typeof StateMachine !== 'undefined' && StateMachine.getState() === 'sleeping') return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const title = await window.clawmate.getActiveWindowTitle();
|
|
42
|
+
if (!title) return;
|
|
43
|
+
|
|
44
|
+
const titleLower = title.toLowerCase();
|
|
45
|
+
const titleChanged = title !== lastTitle;
|
|
46
|
+
lastTitle = title;
|
|
47
|
+
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
|
|
50
|
+
// 카테고리 매칭 (AI/자율 모두 사용)
|
|
51
|
+
const msgs = window._messages;
|
|
52
|
+
const match = msgs?.browsing ? findCategory(titleLower, msgs.browsing) : null;
|
|
53
|
+
const category = match?.category || 'unknown';
|
|
54
|
+
|
|
55
|
+
// 쿨다운 체크
|
|
56
|
+
const isAI = typeof AIController !== 'undefined' && AIController.isConnected();
|
|
57
|
+
const cooldown = isAI ? AI_COOLDOWN : FALLBACK_COOLDOWN;
|
|
58
|
+
if (now - lastCommentTime < cooldown) return;
|
|
59
|
+
|
|
60
|
+
// 같은 카테고리 + 제목 미변경 시 스킵
|
|
61
|
+
if (category === lastCategory && !titleChanged) return;
|
|
62
|
+
|
|
63
|
+
// 확률 체크
|
|
64
|
+
let chance = COMMENT_CHANCE;
|
|
65
|
+
if (titleChanged) chance += SITE_CHANGE_BONUS;
|
|
66
|
+
if (Math.random() > chance) return;
|
|
67
|
+
|
|
68
|
+
// === AI vs 자율 모드 분기 ===
|
|
69
|
+
if (isAI) {
|
|
70
|
+
await reportBrowsingToAI(title, category, titleChanged);
|
|
71
|
+
} else {
|
|
72
|
+
showFallbackComment(match);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lastCategory = category;
|
|
76
|
+
lastCommentTime = now;
|
|
77
|
+
} catch {
|
|
78
|
+
// IPC 실패 무시
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* AI에 브라우징 컨텍스트 전송
|
|
84
|
+
* 제목 + 커서 위치 + 화면 캡처를 한번에 전송
|
|
85
|
+
* AI가 분석하고 코멘트 생성
|
|
86
|
+
*/
|
|
87
|
+
async function reportBrowsingToAI(title, category, titleChanged) {
|
|
88
|
+
if (!window.clawmate.reportToAI) return;
|
|
89
|
+
|
|
90
|
+
// 커서 위치 조회
|
|
91
|
+
let cursorX = 0, cursorY = 0;
|
|
92
|
+
try {
|
|
93
|
+
if (window.clawmate.getCursorPosition) {
|
|
94
|
+
const pos = await window.clawmate.getCursorPosition();
|
|
95
|
+
cursorX = pos.x;
|
|
96
|
+
cursorY = pos.y;
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
// 화면 캡처 (AI가 페이지 내용을 시각적으로 분석하기 위해)
|
|
101
|
+
let screenData = null;
|
|
102
|
+
try {
|
|
103
|
+
const capture = await window.clawmate.screen.capture();
|
|
104
|
+
if (capture?.success) {
|
|
105
|
+
screenData = {
|
|
106
|
+
image: capture.image,
|
|
107
|
+
width: capture.width,
|
|
108
|
+
height: capture.height,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
// 통합 브라우징 리포트 전송
|
|
114
|
+
window.clawmate.reportToAI('browsing', {
|
|
115
|
+
title,
|
|
116
|
+
category,
|
|
117
|
+
titleChanged,
|
|
118
|
+
cursorX,
|
|
119
|
+
cursorY,
|
|
120
|
+
screen: screenData,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 자율 모드 폴백: 프리셋 메시지 표시
|
|
127
|
+
*/
|
|
128
|
+
function showFallbackComment(match) {
|
|
129
|
+
if (!match?.data?.comments) return;
|
|
130
|
+
|
|
131
|
+
const comments = match.data.comments;
|
|
132
|
+
const comment = comments[Math.floor(Math.random() * comments.length)];
|
|
133
|
+
Speech.show(comment);
|
|
134
|
+
|
|
135
|
+
// 50% 확률로 흥분 애니메이션
|
|
136
|
+
if (typeof StateMachine !== 'undefined') {
|
|
137
|
+
const state = StateMachine.getState();
|
|
138
|
+
if ((state === 'idle' || state === 'walking') && Math.random() < 0.5) {
|
|
139
|
+
StateMachine.forceState('excited');
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
if (StateMachine.getState() === 'excited') StateMachine.forceState('idle');
|
|
142
|
+
}, 1500);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 카테고리 매칭 (키워드 기반)
|
|
149
|
+
* general은 다른 카테고리 매칭 안 될 때만 사용
|
|
150
|
+
*/
|
|
151
|
+
function findCategory(titleLower, browsingMsgs) {
|
|
152
|
+
let generalMatch = null;
|
|
153
|
+
for (const [category, data] of Object.entries(browsingMsgs)) {
|
|
154
|
+
if (!data.keywords) continue;
|
|
155
|
+
for (const keyword of data.keywords) {
|
|
156
|
+
if (titleLower.includes(keyword.toLowerCase())) {
|
|
157
|
+
if (category === 'general') {
|
|
158
|
+
generalMatch = { category, data };
|
|
159
|
+
} else {
|
|
160
|
+
return { category, data };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return generalMatch;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function setEnabled(val) { enabled = val; }
|
|
169
|
+
function stop() { if (intervalId) clearInterval(intervalId); }
|
|
170
|
+
|
|
171
|
+
return { init, stop, setEnabled, check };
|
|
172
|
+
})();
|