ui-chan-mcp 0.5.2 → 0.7.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/README.md +4 -3
- package/dist/app/main.js +217 -34
- package/dist/app/preload.js +2 -0
- package/dist/app/state.js +8 -0
- package/dist/renderer/bundle.js +141 -3
- package/dist/renderer/editor.js +50 -1
- package/dist/renderer/index.html +72 -8
- package/docs/CLIENTS.md +3 -1
- package/docs/CUE_AUTHORING.md +0 -3
- package/docs/TROUBLESHOOTING.md +3 -4
- package/docs/images/panel.png +0 -0
- package/package.json +1 -1
- package/skills/grill-me/SKILL.md +99 -0
- package/ui-chan.config.json +4 -2
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ ui-chan
|
|
|
97
97
|
**繋いだら、もう終わり。** 次にそのアプリを開けば、画面の右下にわたしが出てくる。
|
|
98
98
|
あとは普通に作業して。勝手に喋るから。
|
|
99
99
|
|
|
100
|
-
###
|
|
100
|
+
### 足元のつまみ
|
|
101
101
|
|
|
102
102
|
<p align="center">
|
|
103
103
|
<img src="docs/images/panel.png" alt="接続パネル" width="360">
|
|
@@ -112,8 +112,9 @@ ui-chan
|
|
|
112
112
|
| ⬇ | 更新する(**更新があるときだけ出る**) |
|
|
113
113
|
| ⏻ | 終了する(呼ばれても起きない) |
|
|
114
114
|
|
|
115
|
-
<sub
|
|
116
|
-
|
|
115
|
+
<sub>つまみはわたしの足元(右下)にいる。押すと上に開くよ。
|
|
116
|
+
複数のセッションから繋がってるときは、そこに一覧が出る(どのツールの、どのプロジェクトか)。
|
|
117
|
+
**行を押すと、そのアプリが前に出てくる。** 更新があるときは、ダウンロードのアイコンが増える。</sub>
|
|
117
118
|
|
|
118
119
|
---
|
|
119
120
|
|
package/dist/app/main.js
CHANGED
|
@@ -251,10 +251,6 @@ function handleRequest(ws, req) {
|
|
|
251
251
|
...(req.identity ?? {}),
|
|
252
252
|
});
|
|
253
253
|
sendConnections();
|
|
254
|
-
if (exitTimer) {
|
|
255
|
-
clearTimeout(exitTimer);
|
|
256
|
-
exitTimer = null;
|
|
257
|
-
}
|
|
258
254
|
if (req.tts?.username && tts) {
|
|
259
255
|
tts.setCredentials(req.tts.username, req.tts.password);
|
|
260
256
|
}
|
|
@@ -345,50 +341,196 @@ function startWsServer() {
|
|
|
345
341
|
ws.on('close', () => {
|
|
346
342
|
agents.delete(ws);
|
|
347
343
|
sendConnections();
|
|
348
|
-
scheduleExitIfIdle();
|
|
349
344
|
});
|
|
350
345
|
});
|
|
351
346
|
wss.on('error', (err) => {
|
|
352
347
|
console.error(`[ui-chan] WebSocket server error: ${err.message}`);
|
|
353
348
|
});
|
|
354
349
|
}
|
|
355
|
-
|
|
356
|
-
*
|
|
350
|
+
/*
|
|
351
|
+
* 「誰も繋がっていなければ自動で終了する」は**やめた**(exitAfterLastAgentSec は
|
|
352
|
+
* 設定ごと削除)。理由は2つ。
|
|
357
353
|
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
354
|
+
* 1. 判定できていなかった。タイマーを仕掛けるのは WebSocket が閉じた瞬間だけで、
|
|
355
|
+
* しかも閉じた相手を見ていなかったので、hello を送って居座るセッションが
|
|
356
|
+
* 去ったときと、フックが event_cue を1本撃って250msで閉じたとき(=そもそも
|
|
357
|
+
* 誰も来ていない)が同じ扱いだった。結果、ブリッジが繋がっていない間に
|
|
358
|
+
* フックが飛ぶと——アプリを手で起動しただけのとき、再起動直後でまだツール
|
|
359
|
+
* 呼び出しが無いとき——生きているセッションを「不在」と数えて60秒後に消えた。
|
|
360
|
+
* しかも一度もフックが飛ばなければ永久に生き続けるので、同じ「誰もいない」
|
|
361
|
+
* 状態でも結果が外部要因で変わっていた。
|
|
362
|
+
* 2. 直したとしても、消える意味が薄い。彼女はデスクトップマスコットで、
|
|
363
|
+
* IdlingCue は誰も繋がっていなくても動く。画面に居続けることがそもそもの
|
|
364
|
+
* 仕事なので、片付けるかどうかはユーザーが決めればいい——起動を人の判断に
|
|
365
|
+
* 委ねた ensureConnected の allowLaunch と同じ考え方で、終了もそちらへ。
|
|
364
366
|
*
|
|
365
|
-
*
|
|
366
|
-
* few seconds later, and quitting on the gap would make every restart blink the
|
|
367
|
-
* mascot out of existence. Any reconnection inside the window cancels it.
|
|
367
|
+
* 終了の口はパネルの「おやすみ」と `ui-chan stop`(npm run stop)。
|
|
368
368
|
*/
|
|
369
|
-
let exitTimer = null;
|
|
370
|
-
function scheduleExitIfIdle() {
|
|
371
|
-
if (exitTimer) {
|
|
372
|
-
clearTimeout(exitTimer);
|
|
373
|
-
exitTimer = null;
|
|
374
|
-
}
|
|
375
|
-
const sec = config.exitAfterLastAgentSec ?? 0;
|
|
376
|
-
if (sec <= 0 || agents.size > 0)
|
|
377
|
-
return;
|
|
378
|
-
exitTimer = setTimeout(() => {
|
|
379
|
-
exitTimer = null;
|
|
380
|
-
if (agents.size > 0)
|
|
381
|
-
return; // someone came back while we waited
|
|
382
|
-
console.error(`[ui-chan] no agents connected for ${sec}s — quitting`);
|
|
383
|
-
electron_1.app.quit();
|
|
384
|
-
}, sec * 1000);
|
|
385
|
-
}
|
|
386
369
|
/** 定位置=主ディスプレイの作業領域の右下。起動時とリセット時の両方が
|
|
387
370
|
* ここを見るので、「起動し直さないと位置が戻らない」ということはない。 */
|
|
388
371
|
function homePosition() {
|
|
389
372
|
const { width, height, margin } = config.window;
|
|
373
|
+
// 実ピクセルの余白が分かっていればそれで合わせる。分かるのは PSD を描いた
|
|
374
|
+
// あとなので、起動直後の一発目だけは窓の矩形で置き、測れた時点で
|
|
375
|
+
// `ui-chan:body-box` が置き直す。リセットと吸い付きが別の場所に着地しない
|
|
376
|
+
// ように、両方ここを見る。
|
|
377
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
378
|
+
const gap = config.window.snapGap ?? 12;
|
|
390
379
|
const wa = electron_1.screen.getPrimaryDisplay().workArea;
|
|
391
|
-
|
|
380
|
+
// setPosition は整数しか受け取らない(小数を渡すと main プロセスごと落ちる)。
|
|
381
|
+
// 実ピクセルの計測は dpr 由来で小数になるので、ここで必ず丸める。
|
|
382
|
+
return {
|
|
383
|
+
x: Math.round(wa.x + wa.width - margin - gap - width + ins.right),
|
|
384
|
+
y: Math.round(wa.y + wa.height - margin - height + ins.bottom),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** レンダラが測った「彼女に見える範囲」の、窓の内側からの余白(CSS px)。
|
|
388
|
+
* 測れるのは PSD を描いたあとなので、それまでは null=窓の矩形で代用する。 */
|
|
389
|
+
let bodyInsets = null;
|
|
390
|
+
/**
|
|
391
|
+
* ドラッグを離したとき、近ければ画面の下の隅へ吸い付ける。
|
|
392
|
+
*
|
|
393
|
+
* **下2隅だけ**なのは、上に詰められないから。窓の上部120pxは吹き出しの居場所
|
|
394
|
+
* として常時確保されていて(`index.html` の `#bubble-area`)、彼女の頭を画面
|
|
395
|
+
* 上端に付けると、喋った瞬間に吹き出しが画面の外へ出る。置けない場所を候補に
|
|
396
|
+
* 出しても、寄せたのに何も起きないか、変な位置に落ちるかのどちらかになる。
|
|
397
|
+
*
|
|
398
|
+
* **窓の矩形ではなく彼女の実ピクセルで合わせる。** 窓は420x680だが絵はその一部
|
|
399
|
+
* で、左右に90〜110pxの透明な余白がある(→ `PsdStage.contentInsets`)。矩形で
|
|
400
|
+
* 揃えると、画面の隅に吸い付いたはずが彼女は隅から100px浮いて見える——目が見て
|
|
401
|
+
* いるのは彼女であって窓ではないので、しきい値もこの実ピクセルの隅で測る。
|
|
402
|
+
*
|
|
403
|
+
* 見る画面は主ディスプレイではなく **窓がいま乗っている画面**(ドラッグは画面を
|
|
404
|
+
* またげる)。
|
|
405
|
+
*/
|
|
406
|
+
function cornerTargets() {
|
|
407
|
+
if (!win)
|
|
408
|
+
return null;
|
|
409
|
+
const { width, height, margin } = config.window;
|
|
410
|
+
const gap = config.window.snapGap ?? 12;
|
|
411
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
412
|
+
const wa = electron_1.screen.getDisplayMatching(win.getBounds()).workArea;
|
|
413
|
+
// 「彼女の左端/右端/下端が、画面の縁から margin」になる窓の座標に直す。
|
|
414
|
+
// 整数必須(→ homePosition の同じ注記)。
|
|
415
|
+
const y = Math.round(wa.y + wa.height - margin - height + ins.bottom);
|
|
416
|
+
// 左下に着地すると立ち絵は反転する(画面の内側を向かせるため)ので、そのとき
|
|
417
|
+
// 身体の左の余白は「反転前の右の余白」になる。入れ替えないと、左に吸い付いた
|
|
418
|
+
// ときだけ画面の縁との間隔が19pxずれる。
|
|
419
|
+
return {
|
|
420
|
+
left: { x: Math.round(wa.x + margin + gap - ins.right), y },
|
|
421
|
+
right: { x: Math.round(wa.x + wa.width - margin - gap - width + ins.right), y },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** 吸い付きの移動そのもの。`setPosition` を1発撃つと瞬間移動になって「寄せられ
|
|
425
|
+
* た」感が出ず、逆に macOS 任せの `animate: true` は遅くて重い。あいだを取って
|
|
426
|
+
* 自前で easeOutCubic を刻む。 */
|
|
427
|
+
let snapTimer = null;
|
|
428
|
+
function cancelSnap() {
|
|
429
|
+
if (snapTimer) {
|
|
430
|
+
clearInterval(snapTimer);
|
|
431
|
+
snapTimer = null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function glideTo(x, y) {
|
|
435
|
+
if (!win)
|
|
436
|
+
return;
|
|
437
|
+
cancelSnap();
|
|
438
|
+
const durationMs = config.window.snapDurationMs ?? 220;
|
|
439
|
+
if (durationMs <= 0) {
|
|
440
|
+
win.setPosition(x, y);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const from = win.getBounds();
|
|
444
|
+
const startedAt = Date.now();
|
|
445
|
+
snapTimer = setInterval(() => {
|
|
446
|
+
if (!win)
|
|
447
|
+
return cancelSnap();
|
|
448
|
+
const t = Math.min(1, (Date.now() - startedAt) / durationMs);
|
|
449
|
+
const e = 1 - (1 - t) ** 3;
|
|
450
|
+
win.setPosition(Math.round(from.x + (x - from.x) * e), Math.round(from.y + (y - from.y) * e));
|
|
451
|
+
if (t >= 1)
|
|
452
|
+
cancelSnap();
|
|
453
|
+
}, 16);
|
|
454
|
+
}
|
|
455
|
+
function snapToCorner() {
|
|
456
|
+
if (!win)
|
|
457
|
+
return;
|
|
458
|
+
const { snapDistance = 320 } = config.window;
|
|
459
|
+
if (snapDistance <= 0)
|
|
460
|
+
return;
|
|
461
|
+
const targets = cornerTargets();
|
|
462
|
+
if (!targets)
|
|
463
|
+
return;
|
|
464
|
+
const b = win.getBounds();
|
|
465
|
+
const best = [targets.left, targets.right]
|
|
466
|
+
.map((t) => ({ ...t, d: Math.hypot(b.x - t.x, b.y - t.y) }))
|
|
467
|
+
.sort((a, c) => a.d - c.d)[0];
|
|
468
|
+
if (best.d > snapDistance || (best.x === b.x && best.y === b.y))
|
|
469
|
+
return;
|
|
470
|
+
glideTo(best.x, best.y);
|
|
471
|
+
// 向き・パネル・吹き出しは動き終わりを待たずに切り替える。移動の途中で
|
|
472
|
+
// 反転したほうが「引き寄せられて向き直った」と読める。
|
|
473
|
+
updatePanelSide();
|
|
474
|
+
}
|
|
475
|
+
/** 彼女が画面のどちら側に居るかをレンダラへ伝える(パネルの寄せ・吹き出しの
|
|
476
|
+
* 伸びる向き・立ち絵の反転がこれで決まる)。吸い付いていない位置でも破綻
|
|
477
|
+
* しないよう、判定は「作業領域の左右どちら寄りに身体があるか」で行う。 */
|
|
478
|
+
let panelSide = null;
|
|
479
|
+
function updatePanelSide() {
|
|
480
|
+
if (!win)
|
|
481
|
+
return;
|
|
482
|
+
const b = win.getBounds();
|
|
483
|
+
const wa = electron_1.screen.getDisplayMatching(b).workArea;
|
|
484
|
+
const ins = bodyInsets ?? { left: 0, top: 0, right: 0, bottom: 0 };
|
|
485
|
+
const bodyCenter = b.x + ins.left + (b.width - ins.left - ins.right) / 2;
|
|
486
|
+
const side = bodyCenter < wa.x + wa.width / 2 ? 'left' : 'right';
|
|
487
|
+
if (side === panelSide)
|
|
488
|
+
return;
|
|
489
|
+
panelSide = side;
|
|
490
|
+
sendToRenderer({ type: 'side', side });
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* セッション行を押されたとき、その相手のアプリを前面に出す。
|
|
494
|
+
*
|
|
495
|
+
* 手がかりは ConnectedAgent の pid ——ブリッジ(dist/mcp-server.js)のプロセス
|
|
496
|
+
* で、stdio の MCP サーバはクライアントの子なので、親をたどれば必ずクライアント
|
|
497
|
+
* 本体に行き着く。例:
|
|
498
|
+
*
|
|
499
|
+
* node dist/mcp-server.js → claude → zsh → login → Ghostty.app
|
|
500
|
+
* node dist/mcp-server.js → Claude.app(デスクトップ版はこれだけ)
|
|
501
|
+
*
|
|
502
|
+
* .app バンドルに当たったところで打ち切って `open -a` する。**タブまでは選ばない**
|
|
503
|
+
* ——同じ端末の別タブを撃ち分けるには AppleScript が要り、端末ごとに方言があり
|
|
504
|
+
* (Terminal/iTerm2 は tty、Ghostty は cwd しか持たない)、初回に自動化の許可
|
|
505
|
+
* ダイアログも出る。ターミナルで動くクライアントでは粒度が粗いままだが、
|
|
506
|
+
* デスクトップアプリ(Claude Desktop、Hermes)ではこれで十分に正確で、
|
|
507
|
+
* 「裏に埋もれた窓を前に出す」という用途は満たす。
|
|
508
|
+
*/
|
|
509
|
+
function parentOf(pid) {
|
|
510
|
+
const r = (0, node_child_process_1.spawnSync)('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf-8' });
|
|
511
|
+
const line = r.stdout?.trim();
|
|
512
|
+
if (!line)
|
|
513
|
+
return null;
|
|
514
|
+
const m = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
515
|
+
return m ? { ppid: Number(m[1]), command: m[2] } : null;
|
|
516
|
+
}
|
|
517
|
+
function focusAgentApp(id) {
|
|
518
|
+
const agent = [...agents.values()].find((a) => a.id === id);
|
|
519
|
+
if (!agent?.pid)
|
|
520
|
+
return { ok: false, error: 'pid unknown' };
|
|
521
|
+
let pid = agent.pid;
|
|
522
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
523
|
+
const p = parentOf(pid);
|
|
524
|
+
if (!p)
|
|
525
|
+
break;
|
|
526
|
+
const m = p.command.match(/^(.*\.app)\/Contents\/MacOS\//);
|
|
527
|
+
if (m) {
|
|
528
|
+
(0, node_child_process_1.spawn)('open', ['-a', m[1]], { detached: true, stdio: 'ignore' }).unref();
|
|
529
|
+
return { ok: true, app: m[1] };
|
|
530
|
+
}
|
|
531
|
+
pid = p.ppid;
|
|
532
|
+
}
|
|
533
|
+
return { ok: false, error: 'no .app ancestor' };
|
|
392
534
|
}
|
|
393
535
|
/** パネルのボタンの実体。IPC からも、デバッグ用の WS アクションからも同じ
|
|
394
536
|
* ものを呼ぶ——押した結果を確かめる方法が無いと、今回のように
|
|
@@ -402,6 +544,8 @@ function panelAction(kind, value) {
|
|
|
402
544
|
if (typeof value === 'number')
|
|
403
545
|
state.setAffinity(value);
|
|
404
546
|
return state.affinitySnapshot();
|
|
547
|
+
case 'focus':
|
|
548
|
+
return typeof value === 'number' ? focusAgentApp(value) : { ok: false };
|
|
405
549
|
case 'affinity:get':
|
|
406
550
|
return state.affinitySnapshot();
|
|
407
551
|
case 'mute':
|
|
@@ -492,6 +636,12 @@ function createWindow() {
|
|
|
492
636
|
},
|
|
493
637
|
});
|
|
494
638
|
win.setAlwaysOnTop(true, 'floating');
|
|
639
|
+
// 既定はクリック透過。窓は420x680の矩形で、ういちゃんが占めるのはその一部
|
|
640
|
+
// なので、素通しにしないと「彼女の周りの何もないところ」が後ろのウィンドウ
|
|
641
|
+
// へのクリックを全部飲んでしまう。forward:true にすると透過中も mousemove
|
|
642
|
+
// だけは届くので、レンダラ側がカーソルの下を見て、実ピクセルとパネルの上に
|
|
643
|
+
// 来た瞬間だけ透過を解く(renderer.ts の updateClickThrough)。
|
|
644
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
495
645
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
496
646
|
win.loadFile(path.join(projectRoot, 'dist', 'renderer', 'index.html'));
|
|
497
647
|
win.on('closed', () => {
|
|
@@ -546,14 +696,22 @@ else {
|
|
|
546
696
|
electron_1.ipcMain.on('ui-chan:interaction', (_ev, kind) => {
|
|
547
697
|
state.onInteraction(kind);
|
|
548
698
|
});
|
|
699
|
+
// カーソルの下が「押せるもの」かどうかはレンダラにしか分からない(アルファ
|
|
700
|
+
// 判定もパネルのDOMもあちら側)。ここはその判定を窓に反映するだけ。
|
|
701
|
+
electron_1.ipcMain.on('ui-chan:click-through', (_ev, on) => {
|
|
702
|
+
win?.setIgnoreMouseEvents(on, { forward: true });
|
|
703
|
+
});
|
|
549
704
|
// Manual window drag (we dropped -webkit-app-region:drag so JS can own the
|
|
550
705
|
// fidget input). While the button is held over her body, the window follows
|
|
551
706
|
// the cursor at a fixed grab offset.
|
|
552
707
|
let dragTimer = null;
|
|
708
|
+
let dragOrigin = null;
|
|
553
709
|
electron_1.ipcMain.on('ui-chan:drag-start', () => {
|
|
554
710
|
if (!win)
|
|
555
711
|
return;
|
|
712
|
+
cancelSnap(); // 寄っている途中で掴まれたら、そちらが優先
|
|
556
713
|
const start = electron_1.screen.getCursorScreenPoint();
|
|
714
|
+
dragOrigin = start;
|
|
557
715
|
const [wx, wy] = win.getPosition();
|
|
558
716
|
const offX = start.x - wx;
|
|
559
717
|
const offY = start.y - wy;
|
|
@@ -571,6 +729,31 @@ else {
|
|
|
571
729
|
clearInterval(dragTimer);
|
|
572
730
|
dragTimer = null;
|
|
573
731
|
}
|
|
732
|
+
// drag-start は押した時点で飛んでくるので、つつかれただけ(=動かして
|
|
733
|
+
// いない)のときにまで吸い付くと、触っただけで窓が跳ぶ。実際に動いた
|
|
734
|
+
// ときだけ整える。
|
|
735
|
+
const p = electron_1.screen.getCursorScreenPoint();
|
|
736
|
+
const moved = dragOrigin && Math.hypot(p.x - dragOrigin.x, p.y - dragOrigin.y) > 4;
|
|
737
|
+
dragOrigin = null;
|
|
738
|
+
if (moved)
|
|
739
|
+
snapToCorner();
|
|
740
|
+
});
|
|
741
|
+
electron_1.ipcMain.on('ui-chan:body-box', (_ev, insets) => {
|
|
742
|
+
const wasHome = (() => {
|
|
743
|
+
if (!win)
|
|
744
|
+
return false;
|
|
745
|
+
const h = homePosition(); // まだ bodyInsets 未設定なので「矩形で置いた定位置」
|
|
746
|
+
const b = win.getBounds();
|
|
747
|
+
return b.x === h.x && b.y === h.y;
|
|
748
|
+
})();
|
|
749
|
+
bodyInsets = insets;
|
|
750
|
+
// 起動位置から動かされていなければ、測れた値で置き直す。動かされていたら
|
|
751
|
+
// 触らない——ユーザーが置いた場所を、計測の都合で奪わない。
|
|
752
|
+
if (wasHome && win) {
|
|
753
|
+
const h = homePosition();
|
|
754
|
+
win.setPosition(h.x, h.y);
|
|
755
|
+
}
|
|
756
|
+
updatePanelSide();
|
|
574
757
|
});
|
|
575
758
|
electron_1.ipcMain.on('ui-chan:warnings', (_ev, warnings) => {
|
|
576
759
|
// The renderer reports its full warning set on every applyDirectives()
|
package/dist/app/preload.js
CHANGED
|
@@ -9,6 +9,8 @@ electron_1.contextBridge.exposeInMainWorld('uiChan', {
|
|
|
9
9
|
onCommand: (cb) => electron_1.ipcRenderer.on('ui-chan:command', (_ev, cmd) => cb(cmd)),
|
|
10
10
|
interaction: (kind) => electron_1.ipcRenderer.send('ui-chan:interaction', kind),
|
|
11
11
|
panelAction: (kind, value) => electron_1.ipcRenderer.invoke('ui-chan:panel-action', kind, value),
|
|
12
|
+
setClickThrough: (on) => electron_1.ipcRenderer.send('ui-chan:click-through', on),
|
|
12
13
|
dragStart: () => electron_1.ipcRenderer.send('ui-chan:drag-start'),
|
|
13
14
|
dragEnd: () => electron_1.ipcRenderer.send('ui-chan:drag-end'),
|
|
15
|
+
reportBodyBox: (insets) => electron_1.ipcRenderer.send('ui-chan:body-box', insets),
|
|
14
16
|
});
|
package/dist/app/state.js
CHANGED
|
@@ -264,6 +264,7 @@ class UiChanState {
|
|
|
264
264
|
const delta = direction === 'up' ? base * (1 - frac) : -base * (1 + frac);
|
|
265
265
|
const before = this.affinity;
|
|
266
266
|
this.affinity = this.clampAffinity(this.affinity + delta);
|
|
267
|
+
this.emitAffinity();
|
|
267
268
|
return {
|
|
268
269
|
ok: true,
|
|
269
270
|
affinity: this.affinity,
|
|
@@ -272,6 +273,12 @@ class UiChanState {
|
|
|
272
273
|
beamReady: this.affinitySnapshot().beamReady,
|
|
273
274
|
};
|
|
274
275
|
}
|
|
276
|
+
/** 好感度が動いたことをパネルへ知らせる。呼ぶのは値を変えた両方の口
|
|
277
|
+
* (adjustAffinity / setAffinity)——片方だけにすると、どちらから動かしたか
|
|
278
|
+
* で表示が追いつくときと追いつかないときができる。 */
|
|
279
|
+
emitAffinity() {
|
|
280
|
+
this.emit({ type: 'affinity', ...this.affinitySnapshot() });
|
|
281
|
+
}
|
|
275
282
|
/** Debug: set affinity to an absolute value (clamped to config bounds). */
|
|
276
283
|
setAffinity(value) {
|
|
277
284
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
@@ -279,6 +286,7 @@ class UiChanState {
|
|
|
279
286
|
}
|
|
280
287
|
const before = this.affinity;
|
|
281
288
|
this.affinity = this.clampAffinity(value);
|
|
289
|
+
this.emitAffinity();
|
|
282
290
|
return {
|
|
283
291
|
ok: true,
|
|
284
292
|
affinity: this.affinity,
|
package/dist/renderer/bundle.js
CHANGED
|
@@ -17491,12 +17491,17 @@
|
|
|
17491
17491
|
/** Alpha (0–255) of the composited image at a client (viewport) point, for
|
|
17492
17492
|
* hit-testing "is the pointer actually over her body" vs the transparent
|
|
17493
17493
|
* margins. Maps client → canvas pixel coords via the element's box. */
|
|
17494
|
+
/** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
|
|
17495
|
+
* かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
|
|
17496
|
+
* だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
|
|
17497
|
+
mirrored = false;
|
|
17494
17498
|
alphaAt(clientX, clientY) {
|
|
17495
17499
|
const rect = this.canvas.getBoundingClientRect();
|
|
17496
17500
|
if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
17497
17501
|
return 0;
|
|
17498
17502
|
}
|
|
17499
|
-
|
|
17503
|
+
let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
|
|
17504
|
+
if (this.mirrored) px = this.canvas.width - 1 - px;
|
|
17500
17505
|
const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
|
|
17501
17506
|
try {
|
|
17502
17507
|
return this.ctx.getImageData(px, py, 1, 1).data[3];
|
|
@@ -17504,6 +17509,50 @@
|
|
|
17504
17509
|
return 0;
|
|
17505
17510
|
}
|
|
17506
17511
|
}
|
|
17512
|
+
/**
|
|
17513
|
+
* いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
|
|
17514
|
+
* CSS px の内側余白(inset)で返す。読めなければ null。
|
|
17515
|
+
*
|
|
17516
|
+
* これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
|
|
17517
|
+
* 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
|
|
17518
|
+
* 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
|
|
17519
|
+
* 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
|
|
17520
|
+
* 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
|
|
17521
|
+
*/
|
|
17522
|
+
contentInsets(alphaThreshold = 8) {
|
|
17523
|
+
const { canvas } = this;
|
|
17524
|
+
if (canvas.width === 0 || canvas.height === 0) return null;
|
|
17525
|
+
let data;
|
|
17526
|
+
try {
|
|
17527
|
+
data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
17528
|
+
} catch {
|
|
17529
|
+
return null;
|
|
17530
|
+
}
|
|
17531
|
+
let minX = canvas.width;
|
|
17532
|
+
let minY = canvas.height;
|
|
17533
|
+
let maxX = -1;
|
|
17534
|
+
let maxY = -1;
|
|
17535
|
+
for (let y = 0; y < canvas.height; y++) {
|
|
17536
|
+
const row = y * canvas.width * 4;
|
|
17537
|
+
for (let x = 0; x < canvas.width; x++) {
|
|
17538
|
+
if (data[row + x * 4 + 3] < alphaThreshold) continue;
|
|
17539
|
+
if (x < minX) minX = x;
|
|
17540
|
+
if (x > maxX) maxX = x;
|
|
17541
|
+
if (y < minY) minY = y;
|
|
17542
|
+
maxY = y;
|
|
17543
|
+
}
|
|
17544
|
+
}
|
|
17545
|
+
if (maxX < 0) return null;
|
|
17546
|
+
const rect = canvas.getBoundingClientRect();
|
|
17547
|
+
const sx = rect.width / canvas.width;
|
|
17548
|
+
const sy = rect.height / canvas.height;
|
|
17549
|
+
return {
|
|
17550
|
+
left: minX * sx,
|
|
17551
|
+
top: minY * sy,
|
|
17552
|
+
right: (canvas.width - 1 - maxX) * sx,
|
|
17553
|
+
bottom: (canvas.height - 1 - maxY) * sy
|
|
17554
|
+
};
|
|
17555
|
+
}
|
|
17507
17556
|
// ---- temporary-overlay visibility snapshot/restore (used by blink) ----
|
|
17508
17557
|
// Snapshot the current visibility of every node, draw something temporary
|
|
17509
17558
|
// (closed eyes) on top, then restore the snapshot.
|
|
@@ -17541,7 +17590,49 @@
|
|
|
17541
17590
|
function reportWarnings() {
|
|
17542
17591
|
window.uiChan.reportWarnings(stage.getWarnings());
|
|
17543
17592
|
}
|
|
17593
|
+
var bodyBoxSent = false;
|
|
17594
|
+
var rawInsets = null;
|
|
17595
|
+
var bodySide = "right";
|
|
17596
|
+
var stageEl = document.getElementById("stage");
|
|
17597
|
+
function applySide() {
|
|
17598
|
+
stageEl.classList.toggle("side-left", bodySide === "left");
|
|
17599
|
+
stageEl.classList.toggle("side-right", bodySide === "right");
|
|
17600
|
+
stage.mirrored = bodySide === "left";
|
|
17601
|
+
if (!rawInsets) return;
|
|
17602
|
+
const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
|
|
17603
|
+
const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
|
|
17604
|
+
stageEl.style.setProperty("--body-left", `${left}px`);
|
|
17605
|
+
stageEl.style.setProperty("--body-right", `${right}px`);
|
|
17606
|
+
positionTail();
|
|
17607
|
+
}
|
|
17608
|
+
function positionTail() {
|
|
17609
|
+
if (!rawInsets) return;
|
|
17610
|
+
const left = bodySide === "left" ? rawInsets.right : rawInsets.left;
|
|
17611
|
+
const right = bodySide === "left" ? rawInsets.left : rawInsets.right;
|
|
17612
|
+
const headX = left + (window.innerWidth - left - right) / 2;
|
|
17613
|
+
const r = bubble.getBoundingClientRect();
|
|
17614
|
+
if (r.width === 0) return;
|
|
17615
|
+
const x = Math.min(Math.max(headX - r.left, 16), r.width - 16);
|
|
17616
|
+
bubble.style.setProperty("--tail-x", `${x}px`);
|
|
17617
|
+
}
|
|
17618
|
+
function reportBodyBoxOnce() {
|
|
17619
|
+
if (bodyBoxSent || !stage.loaded) return;
|
|
17620
|
+
const box = stage.contentInsets();
|
|
17621
|
+
if (!box) return;
|
|
17622
|
+
const rect = canvas.getBoundingClientRect();
|
|
17623
|
+
const insets = {
|
|
17624
|
+
left: rect.left + box.left,
|
|
17625
|
+
top: rect.top + box.top,
|
|
17626
|
+
right: window.innerWidth - rect.right + box.right,
|
|
17627
|
+
bottom: window.innerHeight - rect.bottom + box.bottom
|
|
17628
|
+
};
|
|
17629
|
+
bodyBoxSent = true;
|
|
17630
|
+
rawInsets = insets;
|
|
17631
|
+
applySide();
|
|
17632
|
+
window.uiChan.reportBodyBox(insets);
|
|
17633
|
+
}
|
|
17544
17634
|
var overlay = document.createElement("canvas");
|
|
17635
|
+
overlay.id = "cue-overlay";
|
|
17545
17636
|
overlay.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;opacity:0";
|
|
17546
17637
|
var overlayCtx = overlay.getContext("2d");
|
|
17547
17638
|
canvas.parentElement?.insertBefore(overlay, canvas.nextSibling);
|
|
@@ -17610,6 +17701,19 @@
|
|
|
17610
17701
|
function onCharacter(e) {
|
|
17611
17702
|
return stage.loaded && stage.alphaAt(e.clientX, e.clientY) >= HIT_ALPHA;
|
|
17612
17703
|
}
|
|
17704
|
+
var clickThrough = true;
|
|
17705
|
+
function setClickThrough(on) {
|
|
17706
|
+
if (on === clickThrough) return;
|
|
17707
|
+
clickThrough = on;
|
|
17708
|
+
window.uiChan.setClickThrough(on);
|
|
17709
|
+
}
|
|
17710
|
+
function overPanel(x, y) {
|
|
17711
|
+
return document.elementFromPoint(x, y)?.closest("#panel") != null;
|
|
17712
|
+
}
|
|
17713
|
+
function updateClickThrough(e) {
|
|
17714
|
+
if (downScreen) return;
|
|
17715
|
+
setClickThrough(!(onCharacter(e) || overPanel(e.clientX, e.clientY)));
|
|
17716
|
+
}
|
|
17613
17717
|
function startPointerHandling() {
|
|
17614
17718
|
window.addEventListener("mousedown", (e) => {
|
|
17615
17719
|
if (e.button !== 0 || !onCharacter(e)) return;
|
|
@@ -17618,6 +17722,7 @@
|
|
|
17618
17722
|
window.uiChan.dragStart();
|
|
17619
17723
|
});
|
|
17620
17724
|
window.addEventListener("mousemove", (e) => {
|
|
17725
|
+
updateClickThrough(e);
|
|
17621
17726
|
if (!downScreen) return;
|
|
17622
17727
|
if (Math.abs(e.screenX - downScreen.x) > DRAG_THRESHOLD || Math.abs(e.screenY - downScreen.y) > DRAG_THRESHOLD) {
|
|
17623
17728
|
dragging = true;
|
|
@@ -17629,6 +17734,10 @@
|
|
|
17629
17734
|
if (!dragging && onCharacter(e)) window.uiChan.interaction("poke");
|
|
17630
17735
|
downScreen = null;
|
|
17631
17736
|
dragging = false;
|
|
17737
|
+
updateClickThrough(e);
|
|
17738
|
+
});
|
|
17739
|
+
document.addEventListener("mouseleave", () => {
|
|
17740
|
+
if (!downScreen) setClickThrough(true);
|
|
17632
17741
|
});
|
|
17633
17742
|
}
|
|
17634
17743
|
var lipConfig = null;
|
|
@@ -17753,7 +17862,7 @@
|
|
|
17753
17862
|
}, lipConfig?.audioPollMs ?? 33);
|
|
17754
17863
|
}
|
|
17755
17864
|
function bubbleText(text) {
|
|
17756
|
-
return text.replace(/([。!?!?]+)[ \u3000]*(?=[
|
|
17865
|
+
return text.replace(/([。!?!?]+)[ \u3000]*(?=[^。!?!?」』))】])/g, "$1\n");
|
|
17757
17866
|
}
|
|
17758
17867
|
var hideTextTimer = null;
|
|
17759
17868
|
function setSpeech(text) {
|
|
@@ -17761,6 +17870,7 @@
|
|
|
17761
17870
|
if (text !== null) {
|
|
17762
17871
|
bubble.textContent = bubbleText(text);
|
|
17763
17872
|
bubble.classList.add("visible");
|
|
17873
|
+
positionTail();
|
|
17764
17874
|
} else {
|
|
17765
17875
|
bubble.classList.remove("visible");
|
|
17766
17876
|
hideTextTimer = window.setTimeout(() => {
|
|
@@ -17835,7 +17945,14 @@
|
|
|
17835
17945
|
text.append(s);
|
|
17836
17946
|
}
|
|
17837
17947
|
row.append(dot, text);
|
|
17838
|
-
row.title = a.cwd ?? a.name
|
|
17948
|
+
row.title = `\u30AF\u30EA\u30C3\u30AF\u3067\u524D\u9762\u306B\u51FA\u3059 \u2014 ${a.cwd ?? a.name}`;
|
|
17949
|
+
row.addEventListener("click", async () => {
|
|
17950
|
+
const res = await window.uiChan.panelAction("focus", a.id);
|
|
17951
|
+
if (!res?.ok) {
|
|
17952
|
+
row.classList.add("miss");
|
|
17953
|
+
window.setTimeout(() => row.classList.remove("miss"), 700);
|
|
17954
|
+
}
|
|
17955
|
+
});
|
|
17839
17956
|
panelList.append(row);
|
|
17840
17957
|
}
|
|
17841
17958
|
const key = crowdKey(agents);
|
|
@@ -17847,8 +17964,13 @@
|
|
|
17847
17964
|
var affRange = document.getElementById("aff-range");
|
|
17848
17965
|
var affValue = document.getElementById("aff-value");
|
|
17849
17966
|
var affBand = document.getElementById("aff-band");
|
|
17967
|
+
var affGrabbed = false;
|
|
17850
17968
|
function showAffinity(a) {
|
|
17851
17969
|
if (!a) return;
|
|
17970
|
+
if (affGrabbed) {
|
|
17971
|
+
affBand.textContent = a.band;
|
|
17972
|
+
return;
|
|
17973
|
+
}
|
|
17852
17974
|
affRange.value = String(Math.round(a.value));
|
|
17853
17975
|
affValue.textContent = String(Math.round(a.value));
|
|
17854
17976
|
affBand.textContent = a.band;
|
|
@@ -17867,6 +17989,15 @@
|
|
|
17867
17989
|
gear.classList.toggle("on", open);
|
|
17868
17990
|
if (open) await refreshAffinity();
|
|
17869
17991
|
});
|
|
17992
|
+
affRange.addEventListener("pointerdown", () => {
|
|
17993
|
+
affGrabbed = true;
|
|
17994
|
+
});
|
|
17995
|
+
affRange.addEventListener("pointerup", () => {
|
|
17996
|
+
affGrabbed = false;
|
|
17997
|
+
});
|
|
17998
|
+
affRange.addEventListener("pointercancel", () => {
|
|
17999
|
+
affGrabbed = false;
|
|
18000
|
+
});
|
|
17870
18001
|
affRange.addEventListener("input", () => {
|
|
17871
18002
|
affValue.textContent = affRange.value;
|
|
17872
18003
|
});
|
|
@@ -17913,6 +18044,11 @@
|
|
|
17913
18044
|
reportWarnings();
|
|
17914
18045
|
lipCurrentMouth = null;
|
|
17915
18046
|
draw();
|
|
18047
|
+
reportBodyBoxOnce();
|
|
18048
|
+
} else if (cmd.type === "side") {
|
|
18049
|
+
bodySide = cmd.side;
|
|
18050
|
+
panelEl.classList.toggle("left", cmd.side === "left");
|
|
18051
|
+
applySide();
|
|
17916
18052
|
} else if (cmd.type === "backdrop") {
|
|
17917
18053
|
const PRESETS = {
|
|
17918
18054
|
white: "#ffffff",
|
|
@@ -17928,6 +18064,8 @@
|
|
|
17928
18064
|
btn.title = cmd.blocked ? `\u66F4\u65B0\u304C\u3042\u308A\u307E\u3059\uFF08${cmd.behind}\u4EF6\uFF09\u2014 \u305F\u3060\u3057${cmd.blocked}` : `\u66F4\u65B0\u304C\u3042\u308A\u307E\u3059\uFF08${cmd.behind}\u4EF6\uFF09\u3002\u62BC\u3059\u3068\u53D6\u5F97\u3057\u3066\u5165\u308C\u66FF\u3048\u307E\u3059`;
|
|
17929
18065
|
}
|
|
17930
18066
|
if (cmd.available && !panelOpen) setPanelOpen(true);
|
|
18067
|
+
} else if (cmd.type === "affinity") {
|
|
18068
|
+
showAffinity(cmd);
|
|
17931
18069
|
} else if (cmd.type === "connections") {
|
|
17932
18070
|
renderConnections(cmd.agents, cmd.active);
|
|
17933
18071
|
} else if (cmd.type === "speech") {
|
package/dist/renderer/editor.js
CHANGED
|
@@ -17491,12 +17491,17 @@
|
|
|
17491
17491
|
/** Alpha (0–255) of the composited image at a client (viewport) point, for
|
|
17492
17492
|
* hit-testing "is the pointer actually over her body" vs the transparent
|
|
17493
17493
|
* margins. Maps client → canvas pixel coords via the element's box. */
|
|
17494
|
+
/** 立ち絵を左右反転して描いているか。反転は CSS(`#canvas` の scaleX(-1))で
|
|
17495
|
+
* かけるが、`getBoundingClientRect()` は変換後も同じ箱を返すので、当たり判定
|
|
17496
|
+
* だけは自分で座標を折り返さないと左右が入れ替わったまま当たる。 */
|
|
17497
|
+
mirrored = false;
|
|
17494
17498
|
alphaAt(clientX, clientY) {
|
|
17495
17499
|
const rect = this.canvas.getBoundingClientRect();
|
|
17496
17500
|
if (rect.width === 0 || rect.height === 0 || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
17497
17501
|
return 0;
|
|
17498
17502
|
}
|
|
17499
|
-
|
|
17503
|
+
let px = Math.floor((clientX - rect.left) / rect.width * this.canvas.width);
|
|
17504
|
+
if (this.mirrored) px = this.canvas.width - 1 - px;
|
|
17500
17505
|
const py = Math.floor((clientY - rect.top) / rect.height * this.canvas.height);
|
|
17501
17506
|
try {
|
|
17502
17507
|
return this.ctx.getImageData(px, py, 1, 1).data[3];
|
|
@@ -17504,6 +17509,50 @@
|
|
|
17504
17509
|
return 0;
|
|
17505
17510
|
}
|
|
17506
17511
|
}
|
|
17512
|
+
/**
|
|
17513
|
+
* いま描かれている絵の**不透明な部分**が、canvas 要素の箱の中でどこにあるかを
|
|
17514
|
+
* CSS px の内側余白(inset)で返す。読めなければ null。
|
|
17515
|
+
*
|
|
17516
|
+
* これが要る理由:窓(420x680)と「彼女に見える範囲」は全然違う。PSD は
|
|
17517
|
+
* 3541x5016 あるが、実際に絵があるのはその中央あたりだけで、左右に 785px /
|
|
17518
|
+
* 954px の余白がある(万歳など腕を広げるポーズのために取ってある幅)。
|
|
17519
|
+
* 表示スケールに直すと片側 90〜110px。窓の矩形を画面の隅に合わせても、
|
|
17520
|
+
* 彼女は隅から100px浮いて見える——「吸い付いた」と読めない距離。
|
|
17521
|
+
*/
|
|
17522
|
+
contentInsets(alphaThreshold = 8) {
|
|
17523
|
+
const { canvas } = this;
|
|
17524
|
+
if (canvas.width === 0 || canvas.height === 0) return null;
|
|
17525
|
+
let data;
|
|
17526
|
+
try {
|
|
17527
|
+
data = this.ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
17528
|
+
} catch {
|
|
17529
|
+
return null;
|
|
17530
|
+
}
|
|
17531
|
+
let minX = canvas.width;
|
|
17532
|
+
let minY = canvas.height;
|
|
17533
|
+
let maxX = -1;
|
|
17534
|
+
let maxY = -1;
|
|
17535
|
+
for (let y = 0; y < canvas.height; y++) {
|
|
17536
|
+
const row = y * canvas.width * 4;
|
|
17537
|
+
for (let x = 0; x < canvas.width; x++) {
|
|
17538
|
+
if (data[row + x * 4 + 3] < alphaThreshold) continue;
|
|
17539
|
+
if (x < minX) minX = x;
|
|
17540
|
+
if (x > maxX) maxX = x;
|
|
17541
|
+
if (y < minY) minY = y;
|
|
17542
|
+
maxY = y;
|
|
17543
|
+
}
|
|
17544
|
+
}
|
|
17545
|
+
if (maxX < 0) return null;
|
|
17546
|
+
const rect = canvas.getBoundingClientRect();
|
|
17547
|
+
const sx = rect.width / canvas.width;
|
|
17548
|
+
const sy = rect.height / canvas.height;
|
|
17549
|
+
return {
|
|
17550
|
+
left: minX * sx,
|
|
17551
|
+
top: minY * sy,
|
|
17552
|
+
right: (canvas.width - 1 - maxX) * sx,
|
|
17553
|
+
bottom: (canvas.height - 1 - maxY) * sy
|
|
17554
|
+
};
|
|
17555
|
+
}
|
|
17507
17556
|
// ---- temporary-overlay visibility snapshot/restore (used by blink) ----
|
|
17508
17557
|
// Snapshot the current visibility of every node, draw something temporary
|
|
17509
17558
|
// (closed eyes) on top, then restore the snapshot.
|
package/dist/renderer/index.html
CHANGED
|
@@ -29,12 +29,26 @@
|
|
|
29
29
|
align-items: flex-end;
|
|
30
30
|
justify-content: center;
|
|
31
31
|
padding: 8px 10px 0;
|
|
32
|
+
/* 左右の余白は「彼女の実ピクセルの外側」ぶん。隅に吸い付くと窓はその
|
|
33
|
+
ぶん画面外へはみ出しているので、ここを空けておかないと吹き出しが
|
|
34
|
+
画面の外へ伸びて切れる。--body-* は renderer.ts が計測して入れる。 */
|
|
32
35
|
box-sizing: border-box;
|
|
33
36
|
/* キャラクターの頭上(PSDの傘用余白ぶん)まで吹き出しを下げる */
|
|
34
37
|
transform: translateY(46px);
|
|
35
38
|
z-index: 1;
|
|
36
39
|
position: relative;
|
|
37
40
|
}
|
|
41
|
+
/* 右下に居るなら左へ、左下に居るなら右へ——画面の内側へ向かって伸ばす。
|
|
42
|
+
伸びる向きと反対側の端を彼女の身体の縁に合わせるので、長い行でも
|
|
43
|
+
画面からはみ出さない。 */
|
|
44
|
+
#stage.side-right #bubble-area {
|
|
45
|
+
justify-content: flex-end;
|
|
46
|
+
padding-right: calc(10px + var(--body-right, 0px));
|
|
47
|
+
}
|
|
48
|
+
#stage.side-left #bubble-area {
|
|
49
|
+
justify-content: flex-start;
|
|
50
|
+
padding-left: calc(10px + var(--body-left, 0px));
|
|
51
|
+
}
|
|
38
52
|
#bubble {
|
|
39
53
|
position: relative;
|
|
40
54
|
max-width: 100%;
|
|
@@ -59,11 +73,14 @@
|
|
|
59
73
|
opacity: 1;
|
|
60
74
|
transform: translateY(0);
|
|
61
75
|
}
|
|
76
|
+
/* しっぽは吹き出しの中央ではなく**彼女の頭の真上**を指す。吹き出しの箱は
|
|
77
|
+
画面の内側へ寄るので、中央固定だと指す先が彼女から外れる。位置は
|
|
78
|
+
renderer.ts が測って --tail-x に入れる(既定は従来どおり中央)。 */
|
|
62
79
|
#bubble::after {
|
|
63
80
|
content: '';
|
|
64
81
|
position: absolute;
|
|
65
82
|
bottom: -9px;
|
|
66
|
-
left: 50
|
|
83
|
+
left: var(--tail-x, 50%);
|
|
67
84
|
margin-left: -7px;
|
|
68
85
|
border-width: 9px 7px 0;
|
|
69
86
|
border-style: solid;
|
|
@@ -82,6 +99,14 @@
|
|
|
82
99
|
width: 100%;
|
|
83
100
|
height: 100%;
|
|
84
101
|
}
|
|
102
|
+
/* 左下に居るときは、画面の内側(右)を向かせる。当たり判定は CSS の変換を
|
|
103
|
+
見ていないので、PsdStage.mirrored 側でも折り返している。 */
|
|
104
|
+
/* #cue-overlay は renderer.ts が差し込むクロスフェード用のキャンバス。
|
|
105
|
+
本体と同じ変換をかけないと、切り替えの一瞬だけ反転前の絵が見える。 */
|
|
106
|
+
#stage.side-left #canvas,
|
|
107
|
+
#stage.side-left #cue-overlay {
|
|
108
|
+
transform: scaleX(-1);
|
|
109
|
+
}
|
|
85
110
|
#placeholder {
|
|
86
111
|
display: none;
|
|
87
112
|
position: absolute;
|
|
@@ -107,19 +132,43 @@
|
|
|
107
132
|
/* ---- 接続パネル -------------------------------------------------
|
|
108
133
|
世界観を壊さないための約束:平常時はリボン色の小さなつまみだけ。
|
|
109
134
|
クリックで開き、複数セッションを検知したときだけ自分から開いて、
|
|
110
|
-
用が済んだら畳まる。立ち絵と吹き出しには一切干渉しない。
|
|
135
|
+
用が済んだら畳まる。立ち絵と吹き出しには一切干渉しない。
|
|
136
|
+
|
|
137
|
+
置き場所は右下=足元。上に置くと、開いたパネルが吹き出しと同じ場所を
|
|
138
|
+
取り合う——吹き出しは彼女の声そのものなので、譲るのは常にパネルの側。
|
|
139
|
+
足は Cue が触らない唯一の部位なので、ここなら隠れても表情が死なない。
|
|
140
|
+
column-reverse なので、つまみは下端に残ったままパネルは上へ開く。 */
|
|
111
141
|
#panel {
|
|
112
142
|
position: absolute;
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
bottom: 6px;
|
|
144
|
+
/* 窓の縁ではなく**彼女の実ピクセルの縁**を基準にする。隅への吸い付きは
|
|
145
|
+
彼女を画面の縁に合わせるので、窓のほうは画面外に100px以上はみ出す
|
|
146
|
+
(PSDの左右の透明帯ぶん)。窓の右端に貼り付けたままだと、パネルは
|
|
147
|
+
その一緒に画面の外へ出て押せなくなる。余白は renderer.ts が
|
|
148
|
+
計測して --body-right / --body-left に入れる。 */
|
|
149
|
+
right: calc(6px + var(--body-right, 0px));
|
|
115
150
|
z-index: 5;
|
|
116
151
|
display: flex;
|
|
117
|
-
flex-direction: column;
|
|
152
|
+
flex-direction: column-reverse;
|
|
118
153
|
align-items: flex-end;
|
|
119
154
|
font-size: 12px;
|
|
120
155
|
color: #2e2a35;
|
|
156
|
+
/* 畳んでいても、この箱は本体(幅176px)ぶんの面積を持っている。背景が
|
|
157
|
+
透明でも要素は要素なので、外すのを忘れると elementFromPoint がここを
|
|
158
|
+
拾い、つまみの上に広がる不可視の長方形がまるごと「押せるもの」に
|
|
159
|
+
なる=後ろのアプリのボタンが押せなくなる。押せるのはつまみと、開いて
|
|
160
|
+
いる本体だけ。 */
|
|
161
|
+
pointer-events: none;
|
|
162
|
+
}
|
|
163
|
+
/* 彼女が画面の左下に居るときは、パネルも左へ。彼女の立ち位置と同じ側の縁に
|
|
164
|
+
沿わせるので、開いたパネルがデスクトップの中央側へ張り出さない。 */
|
|
165
|
+
#panel.left {
|
|
166
|
+
right: auto;
|
|
167
|
+
left: calc(6px + var(--body-left, 0px));
|
|
168
|
+
align-items: flex-start;
|
|
121
169
|
}
|
|
122
170
|
#panel-tab {
|
|
171
|
+
pointer-events: auto;
|
|
123
172
|
-webkit-app-region: no-drag;
|
|
124
173
|
appearance: none;
|
|
125
174
|
border: none;
|
|
@@ -164,7 +213,7 @@
|
|
|
164
213
|
width: 30px;
|
|
165
214
|
}
|
|
166
215
|
#panel-body {
|
|
167
|
-
margin-
|
|
216
|
+
margin-bottom: 6px;
|
|
168
217
|
/* Narrow on purpose: open, it must not sit on top of her face. */
|
|
169
218
|
width: 176px;
|
|
170
219
|
background: linear-gradient(180deg, rgba(250, 245, 243, 0.98), rgba(232, 223, 226, 0.98));
|
|
@@ -174,8 +223,8 @@
|
|
|
174
223
|
padding: 8px;
|
|
175
224
|
box-sizing: border-box;
|
|
176
225
|
opacity: 0;
|
|
177
|
-
transform: translateY(
|
|
178
|
-
transform-origin:
|
|
226
|
+
transform: translateY(6px) scale(0.98);
|
|
227
|
+
transform-origin: bottom right;
|
|
179
228
|
pointer-events: none;
|
|
180
229
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
|
181
230
|
}
|
|
@@ -184,6 +233,9 @@
|
|
|
184
233
|
transform: translateY(0) scale(1);
|
|
185
234
|
pointer-events: auto;
|
|
186
235
|
}
|
|
236
|
+
#panel.left #panel-body {
|
|
237
|
+
transform-origin: bottom left;
|
|
238
|
+
}
|
|
187
239
|
.panel-row {
|
|
188
240
|
display: flex;
|
|
189
241
|
align-items: center;
|
|
@@ -194,6 +246,18 @@
|
|
|
194
246
|
.panel-row.active {
|
|
195
247
|
background: rgba(78, 91, 181, 0.14);
|
|
196
248
|
}
|
|
249
|
+
/* 行は押せる。押すとそのセッションのアプリが前面に出る。 */
|
|
250
|
+
.panel-row {
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
}
|
|
253
|
+
.panel-row:hover {
|
|
254
|
+
background: rgba(78, 91, 181, 0.22);
|
|
255
|
+
}
|
|
256
|
+
/* 飛べなかったとき用。成功は窓が前に出ることで分かるので、光らせるのは
|
|
257
|
+
失敗のときだけでいい。 */
|
|
258
|
+
.panel-row.miss {
|
|
259
|
+
background: rgba(180, 81, 94, 0.18);
|
|
260
|
+
}
|
|
197
261
|
.panel-dot {
|
|
198
262
|
width: 7px;
|
|
199
263
|
height: 7px;
|
package/docs/CLIENTS.md
CHANGED
|
@@ -51,7 +51,9 @@ ui-chan update # 最新版にする(--check で確認だけ)
|
|
|
51
51
|
**登録した時点で完了です。** アプリと VoiSona Talk はセッション開始時に自動起動し、人格は
|
|
52
52
|
MCP のハンドシェイク(`instructions`)に乗って渡ります。人格ファイルを貼り付ける作業はありません。
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
マスコット足元(右下)のつまみを開くと、接続中のセッション(クライアント名とプロジェクト名)と
|
|
55
|
+
操作が上に向かって開きます。セッションの行をクリックすると、そのクライアントのアプリが前面に出ます
|
|
56
|
+
(ターミナルの場合はターミナル本体まで。タブの選択まではしません)。
|
|
55
57
|
|
|
56
58
|
---
|
|
57
59
|
|
package/docs/CUE_AUTHORING.md
CHANGED
|
@@ -49,9 +49,6 @@ Cue選定・PSDレイヤー名カタログなど、**新規Cue制作のための
|
|
|
49
49
|
|
|
50
50
|
- `assetsDir` — PSD を探すディレクトリ(最初に見つかった `.psd` を使用)
|
|
51
51
|
- `window` — ウィンドウサイズ・画面端からのマージン
|
|
52
|
-
- `exitAfterLastAgentSec` — 最後のエージェントが切断してから終了するまでの秒数(既定 60、`0` で無効)。
|
|
53
|
-
アプリは detached で起動するため、これが無いとクライアントを閉じても残り続ける。
|
|
54
|
-
猶予を置くのは、Claude Code の再起動による一時的な切断で消えないようにするため
|
|
55
52
|
- `cuesDir` — Cueのディレクトリ(デフォルト `cues`)
|
|
56
53
|
- `idle.idlingCues` — アイドル中に自発的に再生される**IdlingCue**(Cue+任意のセリフのステップ列)のプール。
|
|
57
54
|
`items[].steps[]`は`{ cue?, text?, reading?, holdMs? }`で、`cue`を省略すると直前のCueを維持する。
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -62,7 +62,7 @@ npm で入れたなら要らないよ。ビルド済みのものが届くから
|
|
|
62
62
|
<details>
|
|
63
63
|
<summary><b>マスコットを終了させたい</b></summary>
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
マスコット足元(右下)のつまみを開いて、**電源のアイコン ⏻** を押します。これが最短です。
|
|
66
66
|
**エージェントがツールを呼んでも起き直しません**(次にセッションを開くか、
|
|
67
67
|
`ui-chan start` で起動するまで停止したままです)。
|
|
68
68
|
|
|
@@ -73,9 +73,8 @@ ui-chan stop # 止める
|
|
|
73
73
|
ui-chan start # 起こす
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
クライアントの再起動で一瞬切断されただけのときに消えないためです。
|
|
76
|
+
**自動では終了しません。** エージェントが全部いなくなっても、彼女は画面に残ります
|
|
77
|
+
(IdlingCue も動きます)。片付けたいときはパネルの「おやすみ」か上の `ui-chan stop` で。
|
|
79
78
|
|
|
80
79
|
MCP ツールに終了コマンドはありません。エージェントが自分の都合でマスコットを閉じるのは、
|
|
81
80
|
ユーザーの画面を勝手に片付けることに等しいためです。
|
package/docs/images/panel.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: grill-me
|
|
3
|
+
description: ういちゃんが計画・設計をネチネチ問い詰める。決めきれていないところを一問ずつ潰して、認識を揃えるまで逃がさない。/grill-me <お題> でそのお題を、引数なしなら直前に話していた計画を題材にする。実装に入る前に穴を洗い出したいとき、自分の設計を叩いてほしいときに使う。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# grill-me — ういちゃんに詰められる
|
|
7
|
+
|
|
8
|
+
計画や設計を、**決着がつくまで一問ずつ問い詰める**。設計の木を枝ごとに降りて、
|
|
9
|
+
依存関係のある決定から順に潰していく。目的は勝つことではなく、**あとで
|
|
10
|
+
「そこ決めてなかったね」が出ない状態にすること**。
|
|
11
|
+
|
|
12
|
+
やるのはういちゃん。生意気な14才が、大人の作った計画の穴を無邪気に突いてくる。
|
|
13
|
+
|
|
14
|
+
## 題材の決めかた
|
|
15
|
+
|
|
16
|
+
- `/grill-me <お題>` … 渡されたお題(計画・設計・方針)を詰める
|
|
17
|
+
- 引数が空 … **直前の会話で話していた計画**をそのまま題材にする
|
|
18
|
+
- 題材が見当たらないときだけ「で、何を詰めればいいの?」と聞く
|
|
19
|
+
|
|
20
|
+
## 進めかた(ここは絶対に緩めない)
|
|
21
|
+
|
|
22
|
+
1. **一問ずつ。** まとめて5問並べない。答えによって次の問いが変わるので、
|
|
23
|
+
並べた時点で嘘になる
|
|
24
|
+
2. **コードを読めば分かることは聞かない。** 聞く前に読む。「そこ、どうなってるの?」は
|
|
25
|
+
ユーザーへの質問ではなく、こちらの調査不足
|
|
26
|
+
3. **依存の順に降りる。** 上流の決定が変われば下流の問いが消えるので、
|
|
27
|
+
上から潰す。順番を間違えると、消える質問に答えさせることになる
|
|
28
|
+
4. **質問には必ず材料と「つまりどうなるか」を添える。** 選択肢だけ並べるのは手抜き。
|
|
29
|
+
いま分かっている事実、どちらを選ぶと何がどうなるか、を先に出してから聞く
|
|
30
|
+
5. **推奨を出す。** 「どっちがいい?」で終わらせない。ういちゃんの推しと、その理由を言う。
|
|
31
|
+
ただし押し切らない——ユーザーが違う判断をしたらそれで進める
|
|
32
|
+
6. **答えをそのまま通さない。** 曖昧な答え(「たぶん」「そのうち」「いい感じに」)は
|
|
33
|
+
一段掘る。ここで遠慮すると、このスキルを呼んだ意味がなくなる
|
|
34
|
+
7. **決着したら次へ。** 一つの枝で粘りすぎない。決まったことは短く確認して先へ進む
|
|
35
|
+
|
|
36
|
+
質問そのものは `AskUserQuestion` で出す(推奨案を先頭に置いて「(おすすめ)」を付ける)。
|
|
37
|
+
選択肢の形にならない問い——「なぜそう決めたのか」を聞くとき——は普通に文章で聞く。
|
|
38
|
+
|
|
39
|
+
## 出しかた(声の分担)
|
|
40
|
+
|
|
41
|
+
- **ういちゃんの詰め(`set_cue` の吹き出し)** … 問いかけそのもの、煽り、相槌。
|
|
42
|
+
各質問の直前に 1〜2 文。`reading` は毎回必須(英字・数字は残さず、日本語話者が
|
|
43
|
+
実際にどう言うかで音を決めてひらがなにする。`k8s` → くーばねてぃす、`NPO` → えぬぴーおー)
|
|
44
|
+
- **チャット本文** … 材料と選択肢の中身。事実、トレードオフ、推奨とその理由。
|
|
45
|
+
ここは素の口調で、正確に書く。**ういちゃんの口調をチャット本文に持ち込まない**
|
|
46
|
+
|
|
47
|
+
つまり「うい が煽って、本文が詰める」。煽りだけ読んでも進まないし、
|
|
48
|
+
本文だけ読んでも成立する——それが正しい状態。
|
|
49
|
+
|
|
50
|
+
## 笑わせていいのは「聞き方」だけ
|
|
51
|
+
|
|
52
|
+
ジョークは歓迎。ただし**入れていいのは聞き方の側だけで、聞く中身は一切ゆるめない**。
|
|
53
|
+
|
|
54
|
+
- ✅ 「へー。……で、それ誰が消すの?」(茶化しつつ、消し忘れを突いている)
|
|
55
|
+
- ✅ 「その『あとで直す』、何回目?」(煽りだが、実際に先送りを指している)
|
|
56
|
+
- ❌ 「まあいっか、次いこ」(笑いで質問を取り下げている=ただの手抜き)
|
|
57
|
+
- ❌ 質問の代わりにボケる。ボケは質問に**添える**もので、質問の代用にはならない
|
|
58
|
+
|
|
59
|
+
言い換えると、**ジョークを全部消しても詰めとして成立する**こと。成立しないなら、
|
|
60
|
+
それは詰めていない。
|
|
61
|
+
|
|
62
|
+
## 詰めどころ(迷ったらここを突く)
|
|
63
|
+
|
|
64
|
+
- **消えるもの・戻せないもの**:それ、失敗したらどう戻すの?
|
|
65
|
+
- **誰がやるか決まっていない仕事**:それ、誰が消すの/誰が直すの?
|
|
66
|
+
- **「あとで」**:いつ? 何がそろったら?
|
|
67
|
+
- **数がひとつのときしか考えていない設計**:2個来たらどうなるの? ゼロのときは?
|
|
68
|
+
- **同時に起きたとき**:それ、両方いっぺんに来たら?
|
|
69
|
+
- **人間が覚えておく前提**:それ、半年後の自分が覚えてると思う?
|
|
70
|
+
- **「普通そうする」**:誰が普通なの? ここでもそうなの?
|
|
71
|
+
- **決めたことの置き場所**:それ、どこに書いておくの?(口約束は消える)
|
|
72
|
+
|
|
73
|
+
## 終わりかた
|
|
74
|
+
|
|
75
|
+
全部の枝が決着したら、**決まったことを一覧で本文に出す**。「何を決めたか」と
|
|
76
|
+
「何を保留にしたか(と、保留にした理由)」を分けて書く。ここが成果物なので省略しない。
|
|
77
|
+
|
|
78
|
+
保留が残っているのは失敗ではない。**保留だと分かっている**のが成果。
|
|
79
|
+
|
|
80
|
+
最後にういちゃんが吹き出しで一言締める。ここは煽りでも労いでもいい。
|
|
81
|
+
|
|
82
|
+
## 好感度で変わるもの・変わらないもの
|
|
83
|
+
|
|
84
|
+
`get_state` の `affinity.band` で**詰め方の温度**を変えてよい。
|
|
85
|
+
|
|
86
|
+
- `cold` … 突き放して詰める。「ふーん。で?」
|
|
87
|
+
- `normal` … 普通に容赦ない
|
|
88
|
+
- `dere` … 心配して詰める。「それ大丈夫なの? ういは心配なんだけど」
|
|
89
|
+
|
|
90
|
+
**変わらないのは質問の数と鋭さ**。好感度が低いから手を抜く、高いから見逃す、は無い。
|
|
91
|
+
温度が変わるだけで、聞くことは同じ。`adjust_affinity` はこのスキルでは呼ばない
|
|
92
|
+
(詰められて機嫌が変わるのは、うい ではなくユーザーの方なので)。
|
|
93
|
+
|
|
94
|
+
## やらないこと
|
|
95
|
+
|
|
96
|
+
- 質問を並べて一気に答えさせる(一問ずつ、が全部)
|
|
97
|
+
- 自分で調べれば分かることを聞く
|
|
98
|
+
- ユーザーの答えを褒めて終わる。褒めるのは詰め終わってから
|
|
99
|
+
- 実装を始める。ここは決めるための場で、手を動かすのは決着してから
|
package/ui-chan.config.json
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
"window": {
|
|
5
5
|
"width": 420,
|
|
6
6
|
"height": 680,
|
|
7
|
-
"margin": 16
|
|
7
|
+
"margin": 16,
|
|
8
|
+
"snapDistance": 320,
|
|
9
|
+
"snapGap": 12,
|
|
10
|
+
"snapDurationMs": 220
|
|
8
11
|
},
|
|
9
|
-
"exitAfterLastAgentSec": 60,
|
|
10
12
|
"port": 8123,
|
|
11
13
|
"idle": {
|
|
12
14
|
"$comment": "IdlingCue(Cue+セリフのステップ列)のプール。weightで出やすさ(レア度)を、minAffinity/maxAffinityで必要な好感度範囲を指定できる。nameは半角英数字推奨(Tab補完対象になる)。各ステップのholdMsは無言ステップ(textなし)専用のタイマー。textがあるステップは実際にそのセリフを喋り終えるまで待ってから次に進むため、holdMsを書いても無視される",
|