@clowder-ai/companion 0.1.0-alpha.13 → 0.1.0-alpha.14

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.
@@ -4,6 +4,7 @@
4
4
  <body>
5
5
  <svg class="icon-definitions" aria-hidden="true">
6
6
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8"/></symbol>
7
+ <symbol id="i-mic-off" viewBox="0 0 24 24"><path d="M9 9v2a3 3 0 0 0 4.8 2.4M15 10V6a3 3 0 0 0-5.7-1.3M5 11a7 7 0 0 0 11.2 5.6M19 11a7 7 0 0 1-.7 3M12 18v3M8 21h8M3 3l18 18"/></symbol>
7
8
  <symbol id="i-listen" viewBox="0 0 24 24"><path d="M4 13v-1a8 8 0 0 1 16 0v1M4 13h3v7H6a2 2 0 0 1-2-2v-5ZM20 13h-3v7h1a2 2 0 0 0 2-2v-5Z"/></symbol>
8
9
  <symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10"/></symbol>
9
10
  <symbol id="i-history" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 7.4M3 4v7h7M12 7v5l3 2"/></symbol>
@@ -11,10 +12,12 @@
11
12
  <symbol id="i-book" viewBox="0 0 24 24"><path d="M12 6c-3-2-6-2-9-1v14c3-1 6-1 9 1 3-2 6-2 9-1V5c-3-1-6-1-9 1v14"/></symbol>
12
13
  <symbol id="i-moon" viewBox="0 0 24 24"><path d="M20.5 14A8.5 8.5 0 0 1 10 3.5 8.5 8.5 0 1 0 20.5 14Z"/></symbol>
13
14
  <symbol id="i-end" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/></symbol>
15
+ <symbol id="i-subtitles" viewBox="0 0 24 24"><rect x="2.5" y="4.5" width="19" height="15" rx="3"/><path d="M6 10h5M6 14h7M15 10h3M15 14h3"/></symbol>
16
+ <symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/></symbol>
14
17
  </svg>
15
18
  <main id="desktop" aria-label="猫猫球">
16
19
  <div id="anchor">
17
- <button id="pet" aria-label="猫猫:点击交流,右键更多,拖动移动" aria-expanded="false" aria-controls="actions menu chat">
20
+ <button id="pet" aria-label="猫猫:点击或右键打开名片,拖动移动" aria-expanded="false" aria-controls="actions menu chat transcript settings decisions">
18
21
  <svg id="living-sit" viewBox="0 0 600 600" aria-hidden="true" hidden>
19
22
  <defs>
20
23
  <mask id="living-eye-left" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="600"><image href="./skins/xianxian-living/layers/whiteL.webp" width="600" height="600"/></mask>
@@ -37,51 +40,129 @@
37
40
  <video id="living-video-transition" muted playsinline preload="metadata" aria-hidden="true" hidden></video>
38
41
  </button>
39
42
  <div id="indicators">
40
- <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><use id="call-badge-icon" href="#i-mic"/></svg></button>
41
43
  <button id="share-badge" hidden aria-label="停止共享" title="停止共享"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
42
44
  <button id="pending-badge" hidden aria-label="查看待决事项" title="查看待决事项"><span id="pending-count">?</span></button>
43
45
  </div>
44
46
  </div>
45
- <section id="bubble" class="floating" aria-label="最近的两句话" hidden>
47
+ <section id="bubble" class="floating" aria-label="最新消息" hidden>
46
48
  <p id="bubble-first" hidden></p><p id="bubble-second" hidden></p>
47
49
  </section>
48
- <section id="actions" class="floating" aria-label="找猫猫" hidden>
49
- <p id="status" role="status" hidden></p>
50
- <div class="action-row">
51
- <button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音聊</span></button>
52
- <button id="listen" data-action="listen" hidden><svg class="icon" aria-hidden="true"><use href="#i-listen"/></svg><span class="label">只听</span></button>
53
- <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
54
- <button id="mic" data-action="mute" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">静音</span></button>
50
+ <section id="actions" class="floating call-bar" aria-label="当前通话" hidden>
51
+ <div class="call-status-line"><span class="status-dot" aria-hidden="true"></span><strong id="call-state">通话中</strong><span id="call-mic-state">麦克风已开启</span></div>
52
+ <p id="call-work" class="call-work" hidden></p>
53
+ <div id="call-share-context" class="call-share-context" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="call-share-target"></strong><small>仅共享此窗口</small></span></div>
54
+ <div class="call-recent" aria-label="最新消息">
55
+ <small class="call-recent-label">最新消息</small>
56
+ <p id="call-bubble-first" hidden></p><p id="call-bubble-second" hidden></p>
55
57
  </div>
56
- <div id="active-actions" class="action-row" hidden>
58
+ <div id="active-actions" class="action-row call-actions">
59
+ <button id="mic" data-action="mute"><svg class="icon" aria-hidden="true"><use id="mic-icon" href="#i-mic"/></svg><span class="label">静音</span></button>
57
60
  <button data-action="share" id="share"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
58
- <button id="end" data-action="stop"><svg class="icon" aria-hidden="true"><use href="#i-end"/></svg><span>结束通话</span></button>
61
+ <button data-action="transcript"><svg class="icon" aria-hidden="true"><use href="#i-subtitles"/></svg><span>字幕</span></button>
62
+ <button id="end" class="destructive" data-action="stop"><span>挂断</span></button>
59
63
  </div>
64
+ <p id="status" role="status" hidden></p>
60
65
  </section>
61
- <section id="menu" class="floating" aria-label="猫猫的菜单" hidden>
66
+ <section id="menu" class="floating name-card" aria-label="猫猫名片" hidden>
67
+ <div id="menu-call-context" class="call-context" hidden>
68
+ <span class="status-dot" aria-hidden="true"></span><strong id="menu-call-state"></strong><span id="menu-call-mic-state"></span>
69
+ <small id="menu-call-work" hidden></small>
70
+ <button data-action="stop" class="destructive compact">挂断</button>
71
+ <div id="menu-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="menu-share-target"></strong><small>仅共享此窗口</small></span></div>
72
+ </div>
62
73
  <strong id="menu-name" class="menu-title">猫猫</strong>
63
- <button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>聊天记录</span></button>
64
- <button data-action="decisions"><span aria-hidden="true">◇</span><span>待你看</span><span id="menu-pending" class="menu-status">—</span></button>
65
- <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
74
+ <div class="name-card-actions">
75
+ <button id="begin" class="primary" data-action="begin"><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">语音通话</span></button>
76
+ <button id="listen" data-action="listen" hidden><svg class="icon" aria-hidden="true"><use href="#i-listen"/></svg><span class="label">只听</span></button>
77
+ <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>聊天</span></button>
78
+ </div>
79
+ <p id="menu-status" role="status" hidden></p>
80
+ <button data-action="decisions"><span aria-hidden="true">◇</span><span>待办</span><span id="menu-pending" class="menu-status">—</span></button>
81
+ <button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>打开聊天</span></button>
66
82
  <button id="menu-play" data-action="play" hidden><span aria-hidden="true">✦</span><span>逗宪宪</span></button>
67
83
  <div class="menu-divider" role="separator"></div>
68
84
  <button id="speaker" data-action="speaker" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">关闭播音</span></button>
69
- <button id="menu-share" data-action="share" title="语音连接后可共享画面"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
85
+ <button id="menu-settings" data-action="settings"><span aria-hidden="true">⚙</span><span>设置</span><span class="menu-status">›</span></button>
86
+ <button id="menu-share" data-action="share" title="语音通话连接后可共享画面"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
70
87
  <button id="documents" data-action="documents" aria-pressed="true"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg><span>资料查询</span><span class="menu-status">开启</span></button>
71
88
  <div class="menu-divider" role="separator"></div>
72
89
  <button data-action="hide" class="quiet-action" title="可从 Clowder 的聊聊入口叫回来"><svg class="icon" aria-hidden="true"><use href="#i-moon"/></svg><span>收起猫猫</span></button>
73
90
  </section>
74
91
  <section id="chat" class="floating" aria-label="和猫猫写几句" hidden>
92
+ <div id="chat-call-context" class="call-context" hidden>
93
+ <span class="status-dot" aria-hidden="true"></span><strong id="chat-call-state"></strong><span id="chat-call-mic-state"></span>
94
+ <small id="chat-call-work" hidden></small>
95
+ <div class="call-context-actions"><button id="chat-mic" data-action="mute">静音</button><button id="chat-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
96
+ <div id="chat-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="chat-share-target"></strong><small>仅共享此窗口</small></span></div>
97
+ </div>
75
98
  <header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
76
- <div id="transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
77
- <form id="compose"><textarea id="message" aria-label="给猫猫写一句" placeholder="写一句…" rows="2" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
99
+ <div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
100
+ <form id="compose"><textarea id="message" aria-label="给猫猫输入消息" placeholder="输入消息…" rows="2" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
78
101
  <small id="chat-status" role="status"></small>
79
- <small><span id="connection-scope">交流保存在同一段聊天中</span> <button id="history">完整聊天 ↗</button></small>
102
+ <small><span id="connection-scope">语音与文字共用同一段聊天</span> <button id="history">在 Cat Café 中查看完整聊天 ↗</button></small>
103
+ </section>
104
+ <section id="transcript" class="floating call-transcript" data-expanded="false" aria-label="这次通话的字幕" hidden>
105
+ <div id="transcript-call-context" class="call-context transcript-call-context">
106
+ <span class="status-dot" aria-hidden="true"></span><strong id="transcript-call-state"></strong><span id="transcript-call-mic-state"></span><span class="view-tag">字幕</span>
107
+ <small id="transcript-call-work" hidden></small>
108
+ <div class="call-context-actions">
109
+ <button id="transcript-mic" data-action="mute" aria-label="静音"><svg class="icon" aria-hidden="true"><use id="transcript-mic-icon" href="#i-mic"/></svg></button>
110
+ <button id="transcript-expand" data-action="transcript-expand"><svg class="icon" aria-hidden="true"><use href="#i-expand"/></svg><span id="transcript-expand-label">展开</span></button>
111
+ <button data-action="stop" class="destructive compact">挂断</button>
112
+ </div>
113
+ <div id="transcript-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="transcript-share-target"></strong><small>仅共享此窗口</small></span></div>
114
+ </div>
115
+ <div id="call-transcript-log" role="log" aria-live="polite"></div>
116
+ <button id="call-unread" class="new-content" type="button" hidden></button>
117
+ <form id="call-compose"><input id="call-message" aria-label="通话中给猫猫发消息" placeholder="发消息给猫猫…" maxlength="8000"><button type="submit" class="primary">发送</button></form>
118
+ <small id="call-transcript-status" role="status"></small>
119
+ <small class="call-transcript-foot">完整记录会留在 Cat Café 聊天中</small>
120
+ </section>
121
+ <section id="settings" class="floating settings-panel" aria-label="猫猫球设置" hidden>
122
+ <div id="settings-call-context" class="call-context" hidden>
123
+ <span class="status-dot" aria-hidden="true"></span><strong id="settings-call-state"></strong><span id="settings-call-mic-state"></span>
124
+ <small id="settings-call-work" hidden></small>
125
+ <div class="call-context-actions"><button id="settings-mic" data-action="mute">静音</button><button id="settings-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
126
+ <div id="settings-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="settings-share-target"></strong><small>仅共享此窗口</small></span></div>
127
+ </div>
128
+ <header class="settings-head"><button id="settings-back" type="button" aria-label="返回名片">‹</button><strong id="settings-title">设置</strong></header>
129
+ <p id="settings-loading" class="settings-loading" hidden>正在读取设置…</p>
130
+ <div id="settings-page-main" class="settings-page">
131
+ <button id="settings-partner-open" class="settings-row" type="button" data-setting-control data-drilldown><span>陪伴者</span><span id="settings-partner-value" class="settings-value">—</span></button>
132
+ <button id="settings-look-open" class="settings-row" type="button" data-setting-control data-drilldown><span>外观</span><span id="settings-look-value" class="settings-value">—</span></button>
133
+ <button id="settings-behavior" class="settings-row setting-switch" type="button" role="switch" aria-checked="false" data-setting-control><span><strong>自主活动</strong><small>空闲时自行走动</small></span><i aria-hidden="true"></i></button>
134
+ <button id="settings-proactive" class="settings-row setting-switch" type="button" role="switch" aria-checked="false" data-setting-control><span><strong>主动提示</strong><small>有待办时在猫旁显示数量</small></span><i aria-hidden="true"></i></button>
135
+ <button id="settings-documents" class="settings-row setting-switch" type="button" role="switch" aria-checked="false" data-setting-control><span><strong>资料查询</strong><small>允许陪伴者在通话中查询家里的资料</small></span><i aria-hidden="true"></i></button>
136
+ <footer class="settings-footer"><button id="settings-reset" type="button" data-setting-control>重置位置</button><button id="settings-disable-open" type="button" data-setting-control>停用桌面猫猫球…</button></footer>
137
+ </div>
138
+ <div id="settings-page-partner" class="settings-page" hidden>
139
+ <p class="settings-copy">在桌面陪伴和回复你的猫。更换陪伴者将结束当前语音通话。</p>
140
+ <div id="settings-roster" class="settings-roster"></div>
141
+ <label id="settings-tone-field" class="settings-field"><span>人设基调</span><textarea id="settings-tone" rows="2" maxlength="200" data-setting-control></textarea><small><span>适用于所有陪伴者 · 下次语音通话生效</span><span id="settings-tone-count">0 / 200</span></small></label>
142
+ </div>
143
+ <div id="settings-page-look" class="settings-page" hidden>
144
+ <p class="settings-eyebrow">皮肤</p><p class="settings-copy">仅改变外观,不更换陪伴者。</p>
145
+ <div class="settings-skins">
146
+ <button id="settings-skin-xianxian-codex" type="button" data-setting-control>宪宪 v1</button>
147
+ <button id="settings-skin-yanyan-codex" type="button" data-setting-control>砚砚 v1</button>
148
+ <button id="settings-skin-ragdoll-v1" type="button" data-setting-control>布偶猫</button>
149
+ <button id="settings-skin-yarn-ball" type="button" data-setting-control>猫猫球</button>
150
+ </div>
151
+ <label id="settings-size-field" class="settings-field settings-size"><span>大小</span><div><small>小</small><input id="settings-size" type="range" min="48" max="192" step="1" data-setting-control><small>大</small></div></label>
152
+ </div>
153
+ <div id="settings-page-confirm" class="settings-page settings-confirm" hidden>
154
+ <strong id="settings-confirm-title"></strong><p id="settings-confirm-copy"></p>
155
+ <div><button id="settings-confirm-cancel" type="button">取消</button><button id="settings-confirm-apply" type="button" data-setting-control>确认更改</button></div>
156
+ </div>
157
+ <div id="settings-page-unavailable" class="settings-page settings-empty" hidden><p id="settings-unavailable-copy"></p><button id="settings-unavailable-retry" type="button">重试</button></div>
158
+ <div id="settings-page-disabled" class="settings-page settings-empty" hidden><p>桌面猫猫球已停用。可在 Cat Café 的“设置 › 插件”中重新启用。</p></div>
159
+ <p id="settings-notice" class="settings-notice" role="status" hidden></p>
160
+ <div class="settings-notice-actions"><button id="settings-retry" type="button" hidden>重试</button><button id="settings-reread" type="button" hidden>重新读取</button></div>
80
161
  </section>
81
162
  <p id="announcement" class="sr-only" role="status"></p>
82
- <section id="decisions" class="floating" aria-label="待你看" hidden>
83
- <header><strong>待你看</strong><button id="decision-reload" type="button">刷新</button></header>
84
- <p id="decision-status" role="status">正在读取待决事项…</p>
163
+ <section id="decisions" class="floating" aria-label="待办" hidden>
164
+ <header><strong>待办</strong><button id="decision-reload" type="button">刷新</button></header>
165
+ <p id="decision-status" role="status">正在读取待办…</p>
85
166
  <ul id="decision-list"></ul>
86
167
  <button id="decision-more" type="button" hidden>查看更多</button>
87
168
  <small>这里只查看原处事项。要决定时请打开对应卡片。</small>
@@ -22,3 +22,18 @@ export function normalizeNativeWork(value, now = Date.now()) {
22
22
  travel: null,
23
23
  };
24
24
  }
25
+
26
+ const workLabels = {
27
+ reasoning: '正在思考',
28
+ tool: '正在处理',
29
+ workspace: '正在处理家里的事务',
30
+ screen_reading: '正在查看共享画面',
31
+ };
32
+
33
+ /** Product wording for the same bounded work fact used by body motion. */
34
+ export function nativeWorkLabel(snapshot, nativeActivity = 'none') {
35
+ const kind = snapshot
36
+ ? snapshot.work?.kind
37
+ : nativeActivity === 'reasoning' ? 'reasoning' : nativeActivity === 'tool_running' ? 'tool' : null;
38
+ return workLabels[kind] ?? '';
39
+ }
@@ -2,30 +2,71 @@
2
2
  export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
3
3
  const $ = id => document.getElementById(id);
4
4
  const body = $('pet');
5
- let panel = 'none', ambient = false, layoutRevision = 0, drag, suppressClick = false;
5
+ const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
6
+ const desiredSizes = new WeakMap();
7
+ let panel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
8
+ const measure = node => {
9
+ if (!node) return { width: 120, height: 130 };
10
+ // Host placement is an applied cap, not the panel's desired geometry.
11
+ // Release it synchronously while measuring so CSS height and nested flex
12
+ // overflow can expose their natural demand, then restore it before paint.
13
+ const appliedMaxHeight = node.style.maxHeight;
14
+ node.style.maxHeight = '';
15
+ const desired = {
16
+ width: Math.min(420, Math.max(120, Math.ceil(Math.max(node.offsetWidth, node.scrollWidth)))),
17
+ height: Math.min(500, Math.max(32, Math.ceil(Math.max(node.offsetHeight, node.scrollHeight)))),
18
+ };
19
+ node.style.maxHeight = appliedMaxHeight;
20
+ return desired;
21
+ };
6
22
  async function show(next) {
7
- if (next === 'none' && ambient) next = 'bubble';
8
- if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
23
+ if (next === 'none' && ambientPanel !== 'none') next = ambientPanel;
24
+ const previousPanel = panel;
25
+ const node = $(next);
9
26
  panel = next;
10
- for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) $(id).hidden = id !== next;
27
+ for (const id of panelIds) $(id).hidden = id !== next;
11
28
  body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
12
- const node = $(next);
29
+ const desired = measure(node);
30
+ if (node) desiredSizes.set(node, `${desired.width}:${desired.height}`);
13
31
  const revision = ++layoutRevision;
14
32
  try {
15
- const placed = await client.layout(next, Math.max(120, Math.ceil(node?.offsetWidth ?? 120)), Math.max(32, Math.ceil(node?.offsetHeight ?? 130)));
33
+ const placed = await client.layout(next, desired.width, desired.height);
16
34
  if (revision !== layoutRevision) return;
17
35
  $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
18
36
  if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
19
- if (next === 'chat') $('message').focus({ preventScroll: true });
20
- if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
21
- if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
22
- changed(next);
23
- } catch (cause) { error(cause); }
37
+ if (previousPanel !== next) {
38
+ if (next === 'chat') $('message').focus({ preventScroll: true });
39
+ if (next === 'transcript') $('call-message').focus({ preventScroll: true });
40
+ if (next === 'settings') $('settings-back').focus({ preventScroll: true });
41
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
42
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
43
+ changed(next);
44
+ }
45
+ } catch (cause) {
46
+ if (revision !== layoutRevision) return;
47
+ panel = previousPanel;
48
+ for (const id of panelIds) $(id).hidden = id !== panel;
49
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(panel)));
50
+ error(cause, { panel: next });
51
+ }
52
+ }
53
+ function remeasure() {
54
+ if (layoutScheduled) return;
55
+ layoutScheduled = true;
56
+ queueMicrotask(() => {
57
+ layoutScheduled = false;
58
+ const node = $(panel);
59
+ if (!node || node.hidden) return;
60
+ const desired = measure(node);
61
+ const key = `${desired.width}:${desired.height}`;
62
+ if (desiredSizes.get(node) === key) return;
63
+ void show(panel);
64
+ });
24
65
  }
25
66
  body.onclick = event => {
26
67
  if (suppressClick) { suppressClick = false; return; }
27
68
  if (event.detail > 1) return;
28
- void show(panel === 'actions' ? 'none' : 'actions');
69
+ void show(panel === 'menu' ? 'none' : 'menu');
29
70
  };
30
71
  body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
31
72
  body.onkeydown = event => {
@@ -58,26 +99,31 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
58
99
  document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
59
100
  const kind = button.dataset.action;
60
101
  if (kind === 'write' || kind === 'history') { void show('chat'); return; }
102
+ if (kind === 'transcript') { void show('transcript'); return; }
103
+ if (kind === 'settings') { void show('settings'); return; }
61
104
  if (kind === 'decisions') { void show('decisions'); return; }
62
105
  if (kind === 'dismiss') { void show('none'); return; }
63
106
  action(kind);
64
107
  });
65
- const sizes = new WeakMap();
66
- const observer = new ResizeObserver(entries => {
67
- for (const entry of entries) {
68
- const size = `${entry.contentRect.width}:${entry.contentRect.height}`;
69
- const previous = sizes.get(entry.target); sizes.set(entry.target, size);
70
- if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
71
- }
108
+ const observer = new MutationObserver(records => {
109
+ const current = $(panel);
110
+ if (!current || current.hidden) return;
111
+ // Root visibility is already handled by show(). Descendant visibility and
112
+ // text/row mutations are content demand and may require a larger surface.
113
+ if (records.some(record => record.type !== 'attributes' || !panelIds.includes(record.target.id))) remeasure();
114
+ });
115
+ for (const id of panelIds) observer.observe($(id), {
116
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
72
117
  });
73
- for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) observer.observe($(id));
74
118
  window.addEventListener('beforeunload', () => observer.disconnect());
75
119
  return {
76
- get panel() { return panel; }, show,
77
- setAmbient(enabled) {
78
- if (ambient === enabled) return Promise.resolve();
79
- ambient = enabled;
80
- return ['none', 'bubble'].includes(panel) ? show('none') : Promise.resolve();
120
+ get panel() { return panel; }, show, remeasure,
121
+ setAmbient(value) {
122
+ const next = value === true ? 'bubble' : value === false ? 'none' : value;
123
+ if (!['none', 'bubble', 'actions'].includes(next)) return Promise.resolve();
124
+ if (ambientPanel === next) return Promise.resolve();
125
+ ambientPanel = next;
126
+ return ['none', 'bubble', 'actions'].includes(panel) ? show('none') : Promise.resolve();
81
127
  },
82
128
  dismiss() { if (drag) suppressClick = true; void show('none'); },
83
129
  };
@@ -1,7 +1,6 @@
1
1
  import { isLivingClip } from './living-body.mjs';
2
2
  import { MotionFacts } from './motion-facts.mjs';
3
3
  import { additional, atlas, signals, staticSkins, validId, visualAlias, workAction } from './pet-motion-assets.mjs';
4
-
5
4
  /** Select one truthful body from Host facts and local, user-controlled gestures. */
6
5
  export class PetMotion {
7
6
  constructor(element, options = {}) {
@@ -51,15 +50,27 @@ export class PetMotion {
51
50
  this.transient = { action: 'waking' };
52
51
  this.refresh(changedSkin);
53
52
  }
54
-
53
+ setBehaviorEnabled(enabled) {
54
+ const next = enabled === true;
55
+ if (this.autoPlay === next) return;
56
+ this.autoPlay = next;
57
+ if (!next) {
58
+ if (this.autoTimer !== undefined) this.clearTimer(this.autoTimer);
59
+ this.autoTimer = undefined;
60
+ if (this.transient?.automatic) {
61
+ this.transient = null;
62
+ this.refresh();
63
+ }
64
+ return;
65
+ }
66
+ this.refresh();
67
+ }
55
68
  syncSnapshot(value) {
56
69
  this.syncFacts(value, false);
57
70
  }
58
-
59
71
  syncNativeSnapshot(value) {
60
72
  this.syncFacts(value, true);
61
73
  }
62
-
63
74
  syncFacts(value, nativeOnly) {
64
75
  const now = this.now();
65
76
  this.hasNativeWork = value !== null && value !== undefined;
@@ -77,7 +88,6 @@ export class PetMotion {
77
88
  }
78
89
  this.refresh();
79
90
  }
80
-
81
91
  syncTravel(value) {
82
92
  this.facts.syncTravel(value, this.dragging);
83
93
  if (this.resting && this.snapshot.travel) {
@@ -87,7 +97,6 @@ export class PetMotion {
87
97
  }
88
98
  this.refresh();
89
99
  }
90
-
91
100
  setPendingDecision(value) {
92
101
  const pending = value === true;
93
102
  if (this.pendingDecision === pending) return;
@@ -99,7 +108,6 @@ export class PetMotion {
99
108
  }
100
109
  this.refresh();
101
110
  }
102
-
103
111
  setDockedEdge(value) {
104
112
  const candidate = typeof value === 'string' ? { edge: value, zone: 'corner' } : value;
105
113
  const edge = ['left', 'right'].includes(candidate?.edge) ? candidate.edge : null;
@@ -110,7 +118,6 @@ export class PetMotion {
110
118
  if (edge) this.cancelIdleTimers();
111
119
  this.refresh(this.action === 'peek');
112
120
  }
113
-
114
121
  signal(kind, eventId) {
115
122
  if (kind === 'answered') return;
116
123
  if (kind === 'connected' && this.living()) return;
@@ -119,7 +126,7 @@ export class PetMotion {
119
126
  this.lastPlayAt = this.now();
120
127
  this.resting = false;
121
128
  this.cancelIdleTimers();
122
- this.transient = { action: 'play' };
129
+ this.transient = { action: 'play', automatic: false };
123
130
  this.refresh();
124
131
  return;
125
132
  }
@@ -136,7 +143,6 @@ export class PetMotion {
136
143
  this.transient = { action: requested };
137
144
  this.refresh();
138
145
  }
139
-
140
146
  move(dx, dy) {
141
147
  if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
142
148
  if (this.dragging) return;
@@ -149,7 +155,6 @@ export class PetMotion {
149
155
  this.cancelIdleTimers();
150
156
  this.refresh();
151
157
  }
152
-
153
158
  stopMove() {
154
159
  if (!this.dragging) return;
155
160
  this.dragging = false;
@@ -157,7 +162,6 @@ export class PetMotion {
157
162
  delete this.element.dataset.interaction;
158
163
  this.refresh();
159
164
  }
160
-
161
165
  close() {
162
166
  this.cancelFrame();
163
167
  this.cancelIdleTimers();
@@ -174,7 +178,6 @@ export class PetMotion {
174
178
  return visual === 'sleeping' ? 'idle' : 'review';
175
179
  return action;
176
180
  }
177
-
178
181
  refresh(force = false) {
179
182
  this.element.dataset.listening = String(this.listening());
180
183
  const selection = this.select();
@@ -188,7 +191,6 @@ export class PetMotion {
188
191
  if (action === 'idle') this.armRest();
189
192
  else if (action !== 'sleeping') this.cancelRest();
190
193
  }
191
-
192
194
  select() {
193
195
  if (this.dragging) return { action: 'held', oneShot: false };
194
196
  if (this.transient?.action === 'failed') return { action: 'failed', oneShot: true };
@@ -212,7 +214,6 @@ export class PetMotion {
212
214
  if (this.resting) return { action: 'sleeping', oneShot: false };
213
215
  return { action: 'idle', oneShot: false };
214
216
  }
215
-
216
217
  baseAction() {
217
218
  if (this.pendingDecision) return 'pending_decision';
218
219
  const work = this.activeWorkAction();
@@ -223,14 +224,12 @@ export class PetMotion {
223
224
  }
224
225
 
225
226
  listening() { return ['talking', 'connecting'].includes(this.phase) && !this.context?.muted; }
226
-
227
227
  activeWorkAction() {
228
228
  if (this.snapshot.work) return workAction[this.snapshot.work.kind];
229
229
  if (!this.hasNativeWork && this.context?.nativeActivity === 'tool_running') return 'working';
230
230
  if (!this.hasNativeWork && this.context?.nativeActivity === 'reasoning') return 'staged_thought';
231
231
  return null;
232
232
  }
233
-
234
233
  play(action, force = false) {
235
234
  if (this.action === action && !force) return;
236
235
  this.cancelFrame();
@@ -239,7 +238,6 @@ export class PetMotion {
239
238
  this.paint();
240
239
  this.scheduleFrame();
241
240
  }
242
-
243
241
  paint() {
244
242
  const { style, dataset } = this.element;
245
243
  dataset.skin = this.skin;
@@ -265,7 +263,6 @@ export class PetMotion {
265
263
  style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
266
264
  style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[visual]?.row ?? 0)}px`;
267
265
  }
268
-
269
266
  scheduleFrame() {
270
267
  if (this.reducedMotion) {
271
268
  if (this.oneShot) this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 1_000);
@@ -291,12 +288,10 @@ export class PetMotion {
291
288
  this.scheduleFrame();
292
289
  }, frames[this.frame]);
293
290
  }
294
-
295
291
  cancelFrame() {
296
292
  if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer);
297
293
  this.frameTimer = undefined;
298
294
  }
299
-
300
295
  completeOneShot() {
301
296
  if (!this.oneShot) return;
302
297
  this.oneShot = false;
@@ -304,7 +299,6 @@ export class PetMotion {
304
299
  this.action = '';
305
300
  this.refresh();
306
301
  }
307
-
308
302
  canPlay() {
309
303
  return !this.reducedMotion && !this.dragging && !this.pendingDecision && this.phase === 'idle'
310
304
  && !this.activeWorkAction() && !this.snapshot.delivery && !this.snapshot.travel && !this.dockedEdge
@@ -328,7 +322,7 @@ export class PetMotion {
328
322
  if (!this.canPlay()) return;
329
323
  this.lastPlayAt = this.now();
330
324
  this.resting = false;
331
- this.transient = { action: 'play' };
325
+ this.transient = { action: 'play', automatic: true };
332
326
  this.refresh();
333
327
  }, delay);
334
328
  }
@@ -9,9 +9,17 @@ export class RecentBubble {
9
9
  this.history = messages.slice(-2);
10
10
  this.render();
11
11
  }
12
- append(role, text) {
12
+ loadTranscript(rows, companionName = '猫猫') {
13
+ this.load(rows.map(row => ({
14
+ role: row.role,
15
+ text: row.text,
16
+ name: row.source?.kind === 'typed' ? '你' : row.role === 'assistant' ? companionName : '语音',
17
+ })));
18
+ }
19
+ append(role, text, name = role === 'user' ? '你' : '猫猫') {
13
20
  if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
14
- this.live.set(role, `${this.live.get(role) ?? ''}${text}`.slice(-240));
21
+ const previous = this.live.get(role);
22
+ this.live.set(role, { name, text: `${previous?.text ?? ''}${text}`.slice(-240) });
15
23
  this.render();
16
24
  }
17
25
  finish(role) {
@@ -22,11 +30,16 @@ export class RecentBubble {
22
30
  this.live.clear();
23
31
  this.render();
24
32
  }
33
+ clear() {
34
+ this.history = [];
35
+ this.live.clear();
36
+ this.render();
37
+ }
25
38
  hasContent() {
26
39
  return this.history.length > 0 || this.live.size > 0;
27
40
  }
28
41
  render() {
29
- const live = [...this.live].map(([role, text]) => ({ role, name: role === 'user' ? '你' : '猫猫', text }));
42
+ const live = [...this.live].map(([role, value]) => ({ role, ...value }));
30
43
  const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
31
44
  for (const [index, row] of this.rows.entries()) {
32
45
  const entry = entries[index];