@clowder-ai/companion 0.1.0-alpha.28 → 0.1.0-alpha.29

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/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.29",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-l4CrAxOELZjC8DrB8BYv8K2pFg1ggCCgarOXhD/tfZ8="
43
+ "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.29",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.29",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-l4CrAxOELZjC8DrB8BYv8K2pFg1ggCCgarOXhD/tfZ8="
43
+ "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -0,0 +1,179 @@
1
+ /** Presentation-only state for the active call surface. Media remains owned by explicit actions. */
2
+ export class CallControlsPresentation {
3
+ constructor({
4
+ document,
5
+ root,
6
+ pet,
7
+ input,
8
+ composers = [],
9
+ send = async () => false,
10
+ accepted = () => {},
11
+ changed = () => {},
12
+ collapseDelay = 1800,
13
+ schedule = (callback, delay) => setTimeout(callback, delay),
14
+ cancel = handle => clearTimeout(handle),
15
+ }) {
16
+ this.document = document;
17
+ this.root = root;
18
+ this.pet = pet;
19
+ this.input = input;
20
+ this.composers = composers;
21
+ this.send = send;
22
+ this.accepted = accepted;
23
+ this.changed = changed;
24
+ this.collapseDelay = collapseDelay;
25
+ this.schedule = schedule;
26
+ this.cancel = cancel;
27
+ this.presentation = 'lite';
28
+ this.panel = 'none';
29
+ this.active = false;
30
+ this.mediaLocked = false;
31
+ this.petHovered = false;
32
+ this.controlsHovered = false;
33
+ this.timer = undefined;
34
+
35
+ this.onPetEnter = () => {
36
+ this.petHovered = true;
37
+ if (this.active) this.reveal();
38
+ };
39
+ this.onPetLeave = () => {
40
+ this.petHovered = false;
41
+ this.armCollapse();
42
+ };
43
+ this.onControlsEnter = () => {
44
+ this.controlsHovered = true;
45
+ this.clearTimer();
46
+ };
47
+ this.onControlsLeave = () => {
48
+ this.controlsHovered = false;
49
+ this.armCollapse();
50
+ };
51
+ this.onFocusIn = () => this.clearTimer();
52
+ this.onFocusOut = () => queueMicrotask(() => this.armCollapse());
53
+ this.onInput = () => this.reevaluate();
54
+
55
+ root.dataset.presentation = this.presentation;
56
+ pet.addEventListener('pointerenter', this.onPetEnter);
57
+ pet.addEventListener('pointerleave', this.onPetLeave);
58
+ root.addEventListener('pointerenter', this.onControlsEnter);
59
+ root.addEventListener('pointerleave', this.onControlsLeave);
60
+ root.addEventListener('focusin', this.onFocusIn);
61
+ root.addEventListener('focusout', this.onFocusOut);
62
+ input.addEventListener('input', this.onInput);
63
+ for (const composer of composers) {
64
+ composer.submit = event => this.submit(event, composer.input);
65
+ composer.form.onsubmit = composer.submit;
66
+ }
67
+ }
68
+
69
+ setComposerState(identity, connecting) {
70
+ for (const { form, input } of this.composers) {
71
+ input.placeholder = identity ? `发消息给${identity.displayName}…` : '发消息给猫猫…';
72
+ form.querySelector('button').disabled = !identity || connecting;
73
+ }
74
+ }
75
+
76
+ async submit(event, input) {
77
+ event.preventDefault();
78
+ const text = input.value;
79
+ if (!await this.send(text)) return;
80
+ if (input.value === text) input.value = '';
81
+ this.reevaluate();
82
+ this.accepted();
83
+ }
84
+
85
+ setActive(active) {
86
+ if (this.active === active) return;
87
+ this.active = active;
88
+ this.clearTimer();
89
+ if (!active) {
90
+ this.mediaLocked = false;
91
+ this.setPresentation('lite');
92
+ return;
93
+ }
94
+ this.setPresentation(this.mediaLocked ? 'controls' : 'lite');
95
+ }
96
+
97
+ setPanel(panel) {
98
+ this.panel = panel;
99
+ if (panel === 'actions') this.armCollapse();
100
+ else this.clearTimer();
101
+ }
102
+
103
+ setMediaLocked(locked) {
104
+ this.mediaLocked = locked;
105
+ if (!this.active) return;
106
+ if (locked) this.reveal();
107
+ else this.armCollapse();
108
+ }
109
+
110
+ activate() {
111
+ if (!this.active) return false;
112
+ this.reveal();
113
+ return true;
114
+ }
115
+
116
+ escape(panel) {
117
+ if (!this.active || panel !== 'actions') return false;
118
+ this.clearTimer();
119
+ if (!this.mediaLocked) this.setPresentation('lite');
120
+ return true;
121
+ }
122
+
123
+ reveal() {
124
+ if (!this.active) return;
125
+ this.clearTimer();
126
+ this.setPresentation('controls');
127
+ }
128
+
129
+ reevaluate() {
130
+ if (this.canAutoCollapse()) this.armCollapse();
131
+ else this.clearTimer();
132
+ }
133
+
134
+ armCollapse() {
135
+ this.clearTimer();
136
+ if (!this.canAutoCollapse()) return;
137
+ this.timer = this.schedule(() => {
138
+ this.timer = undefined;
139
+ if (this.canAutoCollapse()) this.setPresentation('lite');
140
+ }, this.collapseDelay);
141
+ }
142
+
143
+ canAutoCollapse() {
144
+ return this.active
145
+ && this.presentation === 'controls'
146
+ && !this.mediaLocked
147
+ && !this.petHovered
148
+ && !this.controlsHovered
149
+ && !this.root.contains(this.document.activeElement)
150
+ && this.input.value.trim() === '';
151
+ }
152
+
153
+ setPresentation(next) {
154
+ if (this.presentation === next) return;
155
+ this.presentation = next;
156
+ this.root.dataset.presentation = next;
157
+ this.changed(next);
158
+ }
159
+
160
+ clearTimer() {
161
+ if (this.timer === undefined) return;
162
+ this.cancel(this.timer);
163
+ this.timer = undefined;
164
+ }
165
+
166
+ close() {
167
+ this.clearTimer();
168
+ this.pet.removeEventListener('pointerenter', this.onPetEnter);
169
+ this.pet.removeEventListener('pointerleave', this.onPetLeave);
170
+ this.root.removeEventListener('pointerenter', this.onControlsEnter);
171
+ this.root.removeEventListener('pointerleave', this.onControlsLeave);
172
+ this.root.removeEventListener('focusin', this.onFocusIn);
173
+ this.root.removeEventListener('focusout', this.onFocusOut);
174
+ this.input.removeEventListener('input', this.onInput);
175
+ for (const composer of this.composers) {
176
+ if (composer.form.onsubmit === composer.submit) composer.form.onsubmit = null;
177
+ }
178
+ }
179
+ }
@@ -63,6 +63,7 @@
63
63
  <button id="call-chat" data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>聊天</span><span id="call-chat-unread" class="action-unread" hidden></span></button>
64
64
  <button id="end" class="destructive" data-action="stop"><span>挂断</span></button>
65
65
  </div>
66
+ <form id="lite-call-compose"><input id="lite-call-message" aria-label="通话中给猫猫发消息" placeholder="发消息给猫猫…" maxlength="8000"><button type="submit" class="primary">发送</button></form>
66
67
  <p id="status" role="status" hidden></p>
67
68
  </section>
68
69
  <section id="menu" class="floating name-card" aria-label="猫猫名片" hidden>
@@ -1,5 +1,7 @@
1
1
  /** Transient controls. The Host owns native placement, movement and hiding. */
2
- export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
2
+ export function bindPetControls(client, {
3
+ action, error, changed, moved = () => {}, activate = () => false, escape = () => false,
4
+ }) {
3
5
  const $ = id => document.getElementById(id);
4
6
  const body = $('pet');
5
7
  const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
@@ -72,6 +74,7 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
72
74
  body.onclick = event => {
73
75
  if (suppressClick) { suppressClick = false; return; }
74
76
  if (event.detail > 1) return;
77
+ if (activate('click')) return;
75
78
  void show(panel === 'menu' ? 'none' : 'menu');
76
79
  };
77
80
  body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
@@ -100,7 +103,11 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
100
103
  if (!event.target.closest('#anchor, .floating')) void show('none');
101
104
  });
102
105
  document.addEventListener('keydown', event => {
103
- if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
106
+ if (event.key === 'Escape' && panel !== 'none') {
107
+ event.preventDefault();
108
+ if (!escape(panel)) void show('none');
109
+ body.focus();
110
+ }
104
111
  });
105
112
  document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
106
113
  const kind = button.dataset.action;
@@ -86,10 +86,23 @@ button:disabled { opacity: .45; cursor: default; }
86
86
  .floating .destructive:hover:not(:disabled) { background: #942c24; }
87
87
  .floating .destructive.compact { min-width: 72px; padding: 6px 12px; }
88
88
  #actions { padding: 12px; width: 412px; overflow: hidden; }
89
+ #actions[data-presentation="lite"] { width: 320px; display: flex; flex-direction: column; align-items: flex-end; border: 0; background: transparent; box-shadow: none; padding: 0; overflow: visible; }
90
+ #actions[data-presentation="lite"] > :not(.call-status-line, .call-recent) { display: none !important; }
91
+ #actions[data-presentation="lite"] .call-status-line { order: 2; min-height: 29px; width: fit-content; border: 1px solid #fff3; border-radius: 8px; background: var(--desktop-ink); box-shadow: 0 3px 8px #1817152e; color: var(--desktop-paper); padding: 5px 10px; }
92
+ #actions[data-presentation="lite"] .call-status-line strong { color: inherit; font-size: 12px; }
93
+ #actions[data-presentation="lite"] .call-status-line span:not(.status-dot) { color: #d8d1c6; font-size: 11px; }
94
+ #actions[data-presentation="lite"] .call-recent { order: 1; align-self: stretch; display: none; margin: 0 0 8px; border: 1px solid var(--desktop-line-strong); border-radius: 12px; background: var(--desktop-paper); box-shadow: 0 8px 18px #1817151f; padding: 9px 11px; }
95
+ #actions[data-presentation="lite"] .call-recent:has(p:not([hidden])) { display: block; }
96
+ #actions[data-presentation="lite"] .call-recent-label { display: none; }
97
+ #actions[data-presentation="lite"] .call-recent p { margin-top: 0; }
89
98
  .action-row { display: flex; align-items: center; gap: 4px; }
90
99
  .action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
91
100
  .action-row button .icon { width: 16px; height: 16px; }
92
101
  #active-actions { border-top: 1px solid var(--desktop-line); margin-top: 9px; padding-top: 9px; }
102
+ #lite-call-compose { display: none; align-items: center; gap: 8px; border-top: 1px solid var(--desktop-line); margin-top: 9px; padding-top: 9px; }
103
+ #actions[data-presentation="controls"] #lite-call-compose { display: flex; }
104
+ #lite-call-message { min-width: 0; height: 34px; flex: 1; border: 1px solid var(--desktop-line-strong); border-radius: 8px; background: var(--desktop-paper); color: var(--desktop-ink); padding: 6px 9px; }
105
+ #lite-call-compose button { height: 34px; padding: 6px 12px; }
93
106
  #status { font-size: 12px; line-height: 1.45; margin: 8px 0 0; color: var(--desktop-muted); }
94
107
  .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
95
108
  .call-status-line strong { font-size: 13px; font-weight: 600; }
@@ -21,6 +21,7 @@ import { WorkOverviewView } from './work-overview.mjs';
21
21
  import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
22
22
  import { VisibleTextPresentation } from './visible-text-receipt.mjs';
23
23
  import { ConversationSurface } from './conversation-surface.mjs';
24
+ import { CallControlsPresentation } from './call-controls-presentation.mjs';
24
25
  const $ = id => document.getElementById(id);
25
26
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
26
27
  const status = text => {
@@ -50,7 +51,7 @@ const nativeTravel = new NativeWindowTravel({
50
51
  changed: value => motion.syncTravel(value),
51
52
  });
52
53
  let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
53
- let didMove = false, settingsView, settingsValues, decisionPanel, workView;
54
+ let didMove = false, settingsView, settingsValues, decisionPanel, workView, callControlsPresentation;
54
55
  let previousControlPanel = 'none';
55
56
  // F229 stays quiet: a history ID change has no provenance for proactive text.
56
57
  const ambientPanel = active => active ? 'actions' : 'none';
@@ -70,6 +71,8 @@ if (!window.clowderCompanion) {
70
71
  transcript, callTranscript, bubble, callBubble,
71
72
  });
72
73
  const controls = bindPetControls(client, {
74
+ activate: () => { const handled = callControlsPresentation?.activate() ?? false; if (handled) void controls.show('actions'); return handled; },
75
+ escape: panel => callControlsPresentation?.escape(panel) ?? false,
73
76
  error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
74
77
  moved: (dx, dy) => {
75
78
  if (dx !== 'stop') { didMove = true; nativeTravel.setManual(true); motion.move(dx, dy); return; }
@@ -81,6 +84,7 @@ if (!window.clowderCompanion) {
81
84
  setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
82
85
  },
83
86
  changed: panel => {
87
+ callControlsPresentation?.setPanel(panel);
84
88
  visibleText.retry();
85
89
  if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
86
90
  if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
@@ -123,15 +127,23 @@ if (!window.clowderCompanion) {
123
127
  }
124
128
  },
125
129
  });
130
+ callControlsPresentation = new CallControlsPresentation({
131
+ document, root: $('actions'), pet: $('pet'), input: $('lite-call-message'),
132
+ composers: [{ form: $('call-compose'), input: $('call-message') },
133
+ { form: $('lite-call-compose'), input: $('lite-call-message') }],
134
+ send: text => conversation.send(text),
135
+ accepted: () => { motion.signal('received'); void conversationSurface.readHistory(); },
136
+ changed: () => controls.remeasure(),
137
+ });
126
138
  const screen = new ScreenShare({
127
- screenRequest: () => client.screenPick(),
128
- screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
139
+ screenRequest: () => client.screenPick(), screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
129
140
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
130
141
  ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
131
142
  }), screenStop: () => client.screenClose(),
132
143
  }, value => {
133
144
  const wasActive = sharing || pendingScreen;
134
145
  sharing = value.sharing; pendingScreen = value.pending === true;
146
+ callControlsPresentation.setMediaLocked(sharing || pendingScreen);
135
147
  for (const id of ['share', 'menu-share', 'menu-call-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
136
148
  for (const prefix of ['call', 'menu', 'chat', 'transcript', 'settings']) {
137
149
  $(`${prefix}-share-context`).hidden = !sharing;
@@ -181,6 +193,7 @@ if (!window.clowderCompanion) {
181
193
  const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
182
194
  const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
183
195
  visibleText.setCall(value.callId);
196
+ callControlsPresentation.setActive(active);
184
197
  void controls.setAmbient(ambientPanel(active));
185
198
  $('begin').hidden = active; $('begin').disabled = !value.identity;
186
199
  $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
@@ -197,6 +210,7 @@ if (!window.clowderCompanion) {
197
210
  $('share').disabled = value.phase !== 'talking';
198
211
  $('menu-call-share').disabled = value.phase !== 'talking';
199
212
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
213
+ callControlsPresentation.setComposerState(value.identity, value.phase === 'connecting');
200
214
  status(value.message ?? '');
201
215
  $('status').hidden = !value.message || /^(点击|正在听|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
202
216
  $('menu-status').hidden = !value.message;
@@ -232,8 +246,7 @@ if (!window.clowderCompanion) {
232
246
  const companionIdentity = currentCompanionIdentity(identity);
233
247
  renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
234
248
  const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
235
- const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
236
- const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
249
+ const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity), workState = workLabel ? `${identity.displayName}${workLabel}` : '';
237
250
  setTexts(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], workState);
238
251
  setHidden(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], !workState);
239
252
  motion.setContext({ skin: identity?.skin, phase: value.phase,
@@ -248,8 +261,8 @@ if (!window.clowderCompanion) {
248
261
  ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
249
262
  $('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
250
263
  $('message').placeholder = `发消息给${identity.displayName}…`;
251
- $('call-message').placeholder = `发消息给${identity.displayName}…`;
252
- $('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
264
+ $('pet').setAttribute('aria-label', active ? `${companionLabel}:指向或点击显示通话控制,右键打开名片,拖动移动`
265
+ : `${companionLabel}:点击或右键打开名片,拖动移动`);
253
266
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
254
267
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
255
268
  if (companionIdentity) {
@@ -322,28 +335,16 @@ if (!window.clowderCompanion) {
322
335
  } finally { transcriptLoading = false; }
323
336
  }
324
337
  $('share-badge').onclick = toggleScreen;
325
- async function submitCallMessage(event) {
326
- event.preventDefault(); const input = $('call-message'); const text = input.value;
327
- if (await conversation.send(text)) {
328
- motion.signal('received');
329
- if (input.value === text) input.value = '';
330
- void conversationSurface.readHistory();
331
- }
332
- }
333
- $('call-compose').onsubmit = submitCallMessage;
334
338
  $('call-unread').onclick = () => callTranscript.scrollToBottom();
335
339
  const unsubscribe = client.subscribe(event => {
336
340
  if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
337
341
  if (event.kind === 'view-dismiss') controls.dismiss();
338
342
  });
339
- const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
340
- const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
341
- const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
342
- const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
343
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000), transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
344
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000), workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
343
345
  void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
344
346
  window.addEventListener('beforeunload', () => {
345
- clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
346
- conversationSurface.close();
347
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close();
347
348
  unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
348
349
  });
349
350
  }