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

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 CHANGED
@@ -26,9 +26,9 @@ borrowing today's selected companion.
26
26
 
27
27
  ## Host integration
28
28
 
29
- This surface requires the additive desktop bridge **1.5.0**. The Host computes
29
+ This surface requires the additive desktop bridge **1.6.0**. The Host computes
30
30
  placement within the display work area, keeps the cat as its stable anchor, and
31
- owns bounded drag gestures and transparent-area hit testing. Hosts below 1.5
31
+ owns bounded drag gestures and transparent-area hit testing. Hosts below 1.6
32
32
  reject this manifest before installation. Media admission and capture
33
33
  remain in the trusted Host; this package receives neither streams nor provider
34
34
  SDP. Hiding releases media; the existing Clowder **聊聊** entry restores the cat.
@@ -55,6 +55,11 @@ revision and route. If the canonical thread changes, the old unconfirmed input
55
55
  stays visible as invalidated local text and is never replayed into the new scope.
56
56
  The legacy text and conversation methods remain unchanged for older pairs.
57
57
 
58
+ Bridge 1.6 separates passive call speech from the bounded nameplate and control
59
+ panel, and adds an optional application-assistance projection. Assistance is
60
+ off by default; settling a prompt only opens the ordinary menu and grants no
61
+ media, screen-selection or call authority.
62
+
58
63
  Xianxian uses one layered sitting body or one VP9 clip at a time. Expiring Host
59
64
  facts with task, result and event identities select tool work, workspace fetch,
60
65
  screen reading and applied-result delivery. Native travel is independent: the
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.29",
3
+ "version": "0.1.0-alpha.30",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.5.0",
40
+ "bridgeVersion": "1.6.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
43
+ "integrity": "sha256-dR1cqLYEPsY28TJ6ureljiV5CP23wubn70G7pePPkNg="
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.29",
3
+ "version": "0.1.0-alpha.30",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,8 +27,8 @@
27
27
  "prepack": "pnpm build"
28
28
  },
29
29
  "devDependencies": {
30
- "@clowder-ai/plugin-contract": "0.1.0-beta.28",
31
- "@clowder-ai/plugin-sdk": "0.1.0-beta.24",
30
+ "@clowder-ai/plugin-contract": "0.1.0-beta.29",
31
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.25",
32
32
  "playwright": "1.61.1"
33
33
  },
34
34
  "repository": {
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.29",
3
+ "version": "0.1.0-alpha.30",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.5.0",
40
+ "bridgeVersion": "1.6.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
43
+ "integrity": "sha256-dR1cqLYEPsY28TJ6ureljiV5CP23wubn70G7pePPkNg="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -0,0 +1,125 @@
1
+ const ACTIONS = new Set(['dismiss', 'timeout', 'accept']);
2
+
3
+ /**
4
+ * Renderer projection only. The Host owns offer creation, expiry and clearing;
5
+ * this controller merely keeps stale reads and replies from reviving UI or
6
+ * turning an old accept into a new action.
7
+ */
8
+ export class AppAssistController {
9
+ constructor({
10
+ client,
11
+ changed = () => {},
12
+ accepted = () => {},
13
+ scheduleTimeout = setTimeout,
14
+ cancelTimeout = clearTimeout,
15
+ timeoutMs = 15_000,
16
+ }) {
17
+ this.client = client;
18
+ this.changed = changed;
19
+ this.accepted = accepted;
20
+ this.scheduleTimeout = scheduleTimeout;
21
+ this.cancelTimeout = cancelTimeout;
22
+ this.timeoutMs = timeoutMs;
23
+ this.operation = 0;
24
+ this.timer = null;
25
+ this.timerOfferRef = null;
26
+ this.state = {
27
+ scopeRef: null,
28
+ revision: -1,
29
+ status: null,
30
+ offer: null,
31
+ hiddenRef: null,
32
+ pending: null,
33
+ };
34
+ }
35
+
36
+ publish(patch) {
37
+ this.state = { ...this.state, ...patch };
38
+ this.changed(this.state);
39
+ }
40
+
41
+ clearTimer() {
42
+ if (this.timer !== null) this.cancelTimeout(this.timer);
43
+ this.timer = null;
44
+ this.timerOfferRef = null;
45
+ }
46
+
47
+ armTimer(offerRef) {
48
+ if (this.timer !== null && this.timerOfferRef === offerRef) return;
49
+ this.clearTimer();
50
+ this.timerOfferRef = offerRef;
51
+ this.timer = this.scheduleTimeout(() => {
52
+ this.timer = null;
53
+ this.timerOfferRef = null;
54
+ if (this.state.offer?.offerRef === offerRef) void this.settle('timeout');
55
+ }, this.timeoutMs);
56
+ this.timer?.unref?.();
57
+ }
58
+
59
+ consume(snapshot) {
60
+ if (!snapshot || typeof snapshot.scopeRef !== 'string' || !Number.isSafeInteger(snapshot.revision)) return false;
61
+ if (this.state.scopeRef !== null && snapshot.scopeRef !== this.state.scopeRef) return false;
62
+ if (snapshot.revision <= this.state.revision) return false;
63
+
64
+ const scopeRef = this.state.scopeRef ?? snapshot.scopeRef;
65
+ const incomingRef = snapshot.offer?.offerRef ?? null;
66
+ const sameHiddenOffer = incomingRef !== null && incomingRef === this.state.hiddenRef;
67
+ const hiddenRef = incomingRef === null || (this.state.hiddenRef && incomingRef !== this.state.hiddenRef)
68
+ ? null
69
+ : this.state.hiddenRef;
70
+ const offer = sameHiddenOffer ? null : snapshot.offer;
71
+ const pending = incomingRef && this.state.pending?.offerRef !== incomingRef ? null : this.state.pending;
72
+ this.publish({ scopeRef, revision: snapshot.revision, status: snapshot.status, offer, hiddenRef, pending });
73
+ if (offer) this.armTimer(offer.offerRef);
74
+ else this.clearTimer();
75
+ return true;
76
+ }
77
+
78
+ async settle(action) {
79
+ if (!ACTIONS.has(action) || !this.state.offer || !this.state.scopeRef) return false;
80
+ const pending = {
81
+ id: ++this.operation,
82
+ scopeRef: this.state.scopeRef,
83
+ revision: this.state.revision,
84
+ offerRef: this.state.offer.offerRef,
85
+ action,
86
+ };
87
+ this.clearTimer();
88
+ this.publish({ offer: null, hiddenRef: pending.offerRef, pending });
89
+
90
+ let reply;
91
+ try {
92
+ reply = await this.client.settleAppAssist(pending.scopeRef, pending.offerRef, action);
93
+ } catch {
94
+ if (this.state.pending?.id === pending.id) this.publish({ pending: null });
95
+ return false;
96
+ }
97
+ if (this.state.pending?.id !== pending.id) return false;
98
+ const matching = reply?.kind === 'app-assist-settlement'
99
+ && reply.scopeRef === pending.scopeRef
100
+ && reply.offerRef === pending.offerRef
101
+ && reply.action === pending.action
102
+ && Number.isSafeInteger(reply.revision)
103
+ && reply.revision > pending.revision
104
+ && reply.revision >= this.state.revision
105
+ && this.state.status === 'ready';
106
+ if (!matching || reply.outcome !== 'settled') {
107
+ this.publish({ pending: null });
108
+ return false;
109
+ }
110
+
111
+ this.publish({ revision: reply.revision, hiddenRef: null, pending: null });
112
+ if (action === 'accept') this.accepted({
113
+ scopeRef: reply.scopeRef,
114
+ revision: reply.revision,
115
+ offerRef: reply.offerRef,
116
+ });
117
+ return true;
118
+ }
119
+
120
+ close() {
121
+ this.operation++;
122
+ this.clearTimer();
123
+ this.state = { ...this.state, offer: null, pending: null };
124
+ }
125
+ }
@@ -0,0 +1,59 @@
1
+ function formatDuration(elapsedMs) {
2
+ const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
3
+ const hours = Math.floor(seconds / 3600);
4
+ const minutes = Math.floor((seconds % 3600) / 60);
5
+ const remainder = seconds % 60;
6
+ const clock = `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
7
+ return hours ? `${hours}:${clock}` : clock;
8
+ }
9
+
10
+ /** Presentation clock anchored only after the current media connection is confirmed. */
11
+ export class CallDuration {
12
+ constructor(node, {
13
+ now = Date.now,
14
+ schedule = callback => {
15
+ const handle = setInterval(callback, 1000);
16
+ handle.unref?.();
17
+ return handle;
18
+ },
19
+ cancel = handle => clearInterval(handle),
20
+ } = {}) {
21
+ Object.assign(this, { node, now, schedule, cancel });
22
+ this.startedAt = null;
23
+ this.timer = undefined;
24
+ }
25
+
26
+ set(startedAt) {
27
+ startedAt = Number.isFinite(startedAt) ? startedAt : null;
28
+ if (startedAt === this.startedAt) {
29
+ if (startedAt !== null) this.render();
30
+ return;
31
+ }
32
+ this.clear();
33
+ this.startedAt = startedAt;
34
+ this.node.hidden = startedAt === null;
35
+ if (startedAt === null) {
36
+ this.node.textContent = '';
37
+ return;
38
+ }
39
+ this.render();
40
+ this.timer = this.schedule(() => this.render());
41
+ }
42
+
43
+ render() {
44
+ if (this.startedAt === null) return;
45
+ this.node.textContent = formatDuration(this.now() - this.startedAt);
46
+ }
47
+
48
+ clear() {
49
+ if (this.timer === undefined) return;
50
+ this.cancel(this.timer);
51
+ this.timer = undefined;
52
+ }
53
+
54
+ close() {
55
+ this.clear();
56
+ }
57
+ }
58
+
59
+ export { formatDuration };
@@ -95,12 +95,15 @@ export function createCompanionClient(bridge) {
95
95
  openDecision: (variantRef, target) => invoke({ kind: 'decision.open', variantRef, target }, 'navigation'),
96
96
  inspectF221: (proposalId) => invoke({ kind: 'f221.inspect', proposalId }, 'decision-trial'),
97
97
  resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
98
- layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
98
+ layout: (panel, width, height, call) => invoke(call === undefined
99
+ ? { kind: 'view.layout', panel, width, height }
100
+ : { kind: 'view.layout', panel, width, height, call }, 'layout'),
99
101
  drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
100
102
  hide: () => invoke({ kind: 'view.hide' }, 'ok'),
101
103
  resetPosition: () => invoke({ kind: 'view.reset' }, 'ok'),
102
104
  readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
103
105
  updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
106
+ settleAppAssist: (scopeRef, offerRef, action) => invoke({ kind: 'app-assist.settle', scopeRef, offerRef, action }, 'app-assist-settlement'),
104
107
  disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
105
108
  readConversation,
106
109
  readConversationPage,
@@ -3,8 +3,8 @@ import { explainError } from './errors.mjs';
3
3
  /** Local controls only. Durable messages, identity and preferences belong to the Host. */
4
4
  export class CompanionConversation {
5
5
  constructor({ client, createPeer, render, transcript, delivery = () => {}, stopScreen,
6
- uuid = () => crypto.randomUUID() }) {
7
- Object.assign(this, { client, createPeer, render, transcript, delivery, stopScreen, uuid });
6
+ uuid = () => crypto.randomUUID(), now = Date.now }) {
7
+ Object.assign(this, { client, createPeer, render, transcript, delivery, stopScreen, uuid, now });
8
8
  this.active = false;
9
9
  this.generation = 0;
10
10
  this.phase = 'idle';
@@ -20,7 +20,7 @@ export class CompanionConversation {
20
20
  this.message = message;
21
21
  this.render({ phase: this.phase, identity: this.identity, audioMode: this.audioMode, retryMode: this.retryMode,
22
22
  muted: this.muted, silent: this.silent, failed: this.failed, recovering: this.recovering,
23
- callId: this.callId, message });
23
+ callId: this.callId, connectedAt: this.connectedAt ?? null, message });
24
24
  }
25
25
  current(generation) { return this.active && generation === this.generation; }
26
26
  listening() {
@@ -69,6 +69,7 @@ export class CompanionConversation {
69
69
  this.recovering = false;
70
70
  this.phase = 'connecting';
71
71
  this.callId = null;
72
+ this.connectedAt = null;
72
73
  this.expectedHostStop = undefined;
73
74
  const generation = ++this.generation;
74
75
  // Must reach the isolated preload while the click still has user activation.
@@ -82,6 +83,7 @@ export class CompanionConversation {
82
83
  if (event.type === 'connected') {
83
84
  if (this.callId && this.callId !== event.callId) return;
84
85
  this.callId = event.callId;
86
+ this.connectedAt ??= this.now();
85
87
  this.recovering = false;
86
88
  clearTimeout(this.deadline); this.phase = 'talking'; this.show(this.listening());
87
89
  }
@@ -130,6 +132,7 @@ export class CompanionConversation {
130
132
  this.phase = 'idle';
131
133
  this.recovering = false;
132
134
  this.callId = null;
135
+ this.connectedAt = null;
133
136
  this.show(message);
134
137
  await Promise.allSettled([peer?.close(), this.stopScreen()]);
135
138
  }
@@ -17,7 +17,7 @@
17
17
  </svg>
18
18
  <main id="desktop" aria-label="猫猫球">
19
19
  <div id="anchor">
20
- <button id="pet" aria-label="猫猫:点击或右键打开名片,拖动移动" aria-expanded="false" aria-controls="actions menu chat transcript settings decisions">
20
+ <button id="pet" aria-label="猫猫:点击或右键打开名片,拖动移动" aria-expanded="false" aria-controls="actions menu chat transcript settings decisions app-assist">
21
21
  <svg id="living-sit" viewBox="0 0 600 600" aria-hidden="true" hidden>
22
22
  <defs>
23
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>
@@ -47,15 +47,21 @@
47
47
  <section id="bubble" class="floating" aria-label="最新消息" hidden>
48
48
  <p id="bubble-first" hidden></p><p id="bubble-second" hidden></p>
49
49
  </section>
50
+ <section id="app-assist" class="floating app-assist" aria-label="应用旁的轻提示" data-candidate="true" hidden>
51
+ <p>你打开了 <strong id="app-assist-name"></strong>。要我一起看吗?</p>
52
+ <div><button id="app-assist-dismiss" type="button">暂不</button><button id="app-assist-start" class="primary" type="button">打开通话控制</button></div>
53
+ </section>
54
+ <section id="call-bubble" class="floating call-recent" aria-label="通话中的最新话语" hidden>
55
+ <p id="call-bubble-first" hidden></p><p id="call-bubble-second" hidden></p>
56
+ </section>
50
57
  <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-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="call-meeting-label" class="meeting-label"></span><span id="call-meeting-source" class="meeting-source" hidden></span></p>
53
- <p id="call-work" class="call-work" hidden></p>
54
- <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>
55
- <div class="call-recent" aria-label="最新消息">
56
- <small class="call-recent-label">最新消息</small>
57
- <p id="call-bubble-first" hidden></p><p id="call-bubble-second" hidden></p>
58
+ <div class="call-header">
59
+ <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>
60
+ <p id="call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="call-meeting-label" class="meeting-label"></span><span id="call-meeting-source" class="meeting-source" hidden></span></p>
61
+ <p id="call-work" class="call-work" hidden></p>
62
+ <time id="call-duration" class="call-duration" aria-label="通话时长" hidden></time>
58
63
  </div>
64
+ <div id="call-share-context" class="call-share-context" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><strong id="call-share-target"></strong><small> · 仅此窗口</small></div>
59
65
  <div id="active-actions" class="action-row call-actions">
60
66
  <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>
61
67
  <button data-action="share" id="share"><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span class="label">共享画面</span></button>
@@ -185,6 +191,7 @@
185
191
  <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>
186
192
  <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>
187
193
  <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>
194
+ <button id="settings-app-assist" 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>
188
195
  <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>
189
196
  <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>
190
197
  </div>
@@ -4,7 +4,9 @@ export function bindPetControls(client, {
4
4
  }) {
5
5
  const $ = id => document.getElementById(id);
6
6
  const body = $('pet');
7
- const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
7
+ const panelIds = ['actions', 'menu', 'chat', 'bubble', 'app-assist', 'decisions', 'transcript', 'settings'];
8
+ const callBubble = $('call-bubble');
9
+ const managedIds = [...panelIds, 'call-bubble'];
8
10
  const desiredSizes = new WeakMap();
9
11
  let panel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
10
12
  const measure = node => {
@@ -21,6 +23,24 @@ export function bindPetControls(client, {
21
23
  node.style.maxHeight = appliedMaxHeight;
22
24
  return desired;
23
25
  };
26
+ const hasCallBubble = () => callBubble?.querySelector('p:not([hidden])') !== null;
27
+ const callLayout = next => {
28
+ if (next !== 'actions') {
29
+ if (callBubble) callBubble.hidden = true;
30
+ return undefined;
31
+ }
32
+ const presentation = $('actions').dataset.presentation === 'controls' ? 'controls' : 'lite';
33
+ const call = { presentation };
34
+ if (callBubble && hasCallBubble()) {
35
+ callBubble.hidden = false;
36
+ call.bubble = measure(callBubble);
37
+ } else if (callBubble) callBubble.hidden = true;
38
+ return call;
39
+ };
40
+ const sizeKey = (desired, call) => [
41
+ desired.width, desired.height, call?.presentation ?? '',
42
+ call?.bubble?.width ?? '', call?.bubble?.height ?? '',
43
+ ].join(':');
24
44
  async function show(next) {
25
45
  if (next === 'none' && ambientPanel !== 'none') next = ambientPanel;
26
46
  const previousPanel = panel;
@@ -28,14 +48,24 @@ export function bindPetControls(client, {
28
48
  panel = next;
29
49
  for (const id of panelIds) $(id).hidden = id !== next;
30
50
  body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
51
+ const call = callLayout(next);
31
52
  const desired = measure(node);
32
- if (node) desiredSizes.set(node, `${desired.width}:${desired.height}`);
53
+ if (node) desiredSizes.set(node, sizeKey(desired, call));
33
54
  const revision = ++layoutRevision;
34
55
  try {
35
- const placed = await client.layout(next, desired.width, desired.height);
56
+ const placed = await client.layout(next, desired.width, desired.height, call);
36
57
  if (revision !== layoutRevision) return;
37
58
  $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
38
59
  if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
60
+ if (callBubble) {
61
+ const visible = next === 'actions' && call?.bubble && placed.bubble;
62
+ callBubble.hidden = !visible;
63
+ if (visible) {
64
+ callBubble.style.left = `${placed.bubble.x}px`;
65
+ callBubble.style.top = `${placed.bubble.y}px`;
66
+ callBubble.style.maxHeight = `${placed.bubble.height}px`;
67
+ }
68
+ }
39
69
  if (previousPanel !== next) {
40
70
  if (next === 'chat') {
41
71
  const mode = $('chat').dataset.replyMode;
@@ -54,6 +84,7 @@ export function bindPetControls(client, {
54
84
  if (revision !== layoutRevision) return;
55
85
  panel = previousPanel;
56
86
  for (const id of panelIds) $(id).hidden = id !== panel;
87
+ if (callBubble) callBubble.hidden = panel !== 'actions' || !hasCallBubble();
57
88
  body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(panel)));
58
89
  error(cause, { panel: next });
59
90
  }
@@ -65,8 +96,9 @@ export function bindPetControls(client, {
65
96
  layoutScheduled = false;
66
97
  const node = $(panel);
67
98
  if (!node || node.hidden) return;
99
+ const call = callLayout(panel);
68
100
  const desired = measure(node);
69
- const key = `${desired.width}:${desired.height}`;
101
+ const key = sizeKey(desired, call);
70
102
  if (desiredSizes.get(node) === key) return;
71
103
  void show(panel);
72
104
  });
@@ -123,20 +155,24 @@ export function bindPetControls(client, {
123
155
  if (!current || current.hidden) return;
124
156
  // Root visibility is already handled by show(). Descendant visibility and
125
157
  // text/row mutations are content demand and may require a larger surface.
126
- if (records.some(record => record.type !== 'attributes' || !panelIds.includes(record.target.id))) remeasure();
158
+ if (records.some(record => record.type !== 'attributes' || !managedIds.includes(record.target.id))) remeasure();
127
159
  });
128
160
  for (const id of panelIds) observer.observe($(id), {
129
161
  subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
130
162
  });
163
+ if (callBubble) observer.observe(callBubble, {
164
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
165
+ });
131
166
  window.addEventListener('beforeunload', () => observer.disconnect());
132
167
  return {
133
168
  get panel() { return panel; }, show, remeasure,
134
169
  setAmbient(value) {
135
170
  const next = value === true ? 'bubble' : value === false ? 'none' : value;
136
- if (!['none', 'bubble', 'actions'].includes(next)) return Promise.resolve();
171
+ const ambientPanels = ['none', 'bubble', 'actions', 'app-assist'];
172
+ if (!ambientPanels.includes(next)) return Promise.resolve();
137
173
  if (ambientPanel === next) return Promise.resolve();
138
174
  ambientPanel = next;
139
- return ['none', 'bubble', 'actions'].includes(panel) ? show('none') : Promise.resolve();
175
+ return ambientPanels.includes(panel) ? show('none') : Promise.resolve();
140
176
  },
141
177
  dismiss() { if (drag) suppressClick = true; void show('none'); },
142
178
  };
@@ -6,8 +6,9 @@ function referenceKey(reference) {
6
6
  }
7
7
 
8
8
  export class RecentBubble {
9
- constructor(rows) {
9
+ constructor(rows, { showSpeaker = true } = {}) {
10
10
  this.rows = rows;
11
+ this.showSpeaker = showSpeaker;
11
12
  this.history = [];
12
13
  this.live = new Map();
13
14
  this.rowReferences = new WeakMap();
@@ -58,7 +59,7 @@ export class RecentBubble {
58
59
  for (const [index, row] of this.rows.entries()) {
59
60
  const entry = entries[index];
60
61
  row.hidden = !entry;
61
- const speaker = entry ? `${String(entry.name).slice(0, 28)}:` : '';
62
+ const speaker = entry && this.showSpeaker ? `${String(entry.name).slice(0, 28)}:` : '';
62
63
  const message = entry ? String(entry.text).slice(-120) : '';
63
64
  row.textContent = `${speaker}${message}`;
64
65
  if (entry && row.ownerDocument?.createElement && typeof row.replaceChildren === 'function') {
@@ -66,6 +67,7 @@ export class RecentBubble {
66
67
  const messageNode = row.ownerDocument.createElement('span');
67
68
  const messageText = row.ownerDocument.createElement('bdi');
68
69
  speakerNode.className = 'recent-speaker';
70
+ speakerNode.hidden = !speaker;
69
71
  messageNode.className = 'recent-message';
70
72
  messageText.className = 'recent-message-text';
71
73
  messageText.dir = 'auto';
@@ -14,6 +14,7 @@ const fieldLabels = {
14
14
  proactivePolicy: '主动提示',
15
15
  personaTone: '人设基调',
16
16
  householdReadsAllowed: '资料查询',
17
+ appAssistEnabled: '应用旁问一句',
17
18
  };
18
19
 
19
20
  export class SettingsView {
@@ -44,6 +45,9 @@ export class SettingsView {
44
45
  this.$('settings-proactive').onclick = () => void this.controller.requestUpdate(
45
46
  'proactivePolicy', this.controller.state.values?.proactivePolicy === 'quiet-badge' ? 'ambient' : 'quiet-badge',
46
47
  );
48
+ this.$('settings-app-assist').onclick = () => void this.controller.requestUpdate(
49
+ 'appAssistEnabled', !this.controller.state.values?.appAssistEnabled,
50
+ );
47
51
  this.$('settings-documents').onclick = () => {
48
52
  const value = !this.controller.state.values?.householdReadsAllowed;
49
53
  void this.controller.requestUpdate('householdReadsAllowed', value, { confirm: this.callActive() });
@@ -123,6 +127,7 @@ export class SettingsView {
123
127
  this.$('settings-look-value').textContent = skinLabels[values.skin] ?? values.skin;
124
128
  this.setToggle('settings-behavior', values.behaviorEnabled);
125
129
  this.setToggle('settings-proactive', values.proactivePolicy === 'quiet-badge');
130
+ this.setToggle('settings-app-assist', values.appAssistEnabled);
126
131
  this.setToggle('settings-documents', values.householdReadsAllowed);
127
132
  this.$('settings-size').value = String(values.ballSize);
128
133
  this.$('settings-tone').value = values.personaTone;
@@ -139,7 +144,8 @@ export class SettingsView {
139
144
  if (field === 'skin') return skinLabels[value] ?? value;
140
145
  if (field === 'dutyCatProfileId')
141
146
  return state.companions?.find(item => item.catProfileId === value)?.displayName ?? '已保存的陪伴者';
142
- if (field === 'behaviorEnabled' || field === 'householdReadsAllowed') return value ? '开启' : '关闭';
147
+ if (field === 'behaviorEnabled' || field === 'householdReadsAllowed' || field === 'appAssistEnabled')
148
+ return value ? '开启' : '关闭';
143
149
  if (field === 'proactivePolicy') return value === 'quiet-badge' ? '开启' : '关闭';
144
150
  if (field === 'ballSize') return `${value}`;
145
151
  return value;
@@ -169,12 +175,13 @@ export class SettingsView {
169
175
 
170
176
  renderSettlement(state) {
171
177
  for (const id of ['settings-partner-open', 'settings-look-open', 'settings-behavior',
172
- 'settings-proactive', 'settings-documents', 'settings-tone-field', 'settings-size-field'])
178
+ 'settings-proactive', 'settings-app-assist', 'settings-documents', 'settings-tone-field', 'settings-size-field'])
173
179
  delete this.$(id).dataset.settlement;
174
180
  if (state.notice?.kind !== 'unconfirmed') return;
175
181
  const ids = {
176
182
  dutyCatProfileId: 'settings-partner-open', skin: 'settings-look-open',
177
183
  behaviorEnabled: 'settings-behavior', proactivePolicy: 'settings-proactive',
184
+ appAssistEnabled: 'settings-app-assist',
178
185
  householdReadsAllowed: 'settings-documents', personaTone: 'settings-tone-field',
179
186
  ballSize: 'settings-size-field',
180
187
  };
@@ -78,6 +78,16 @@ button:disabled { opacity: .45; cursor: default; }
78
78
  #bubble p { margin: 0; display: flex; min-width: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; }
79
79
  #bubble p + p { margin-top: 4px; }
80
80
  #bubble [data-role="user"] { color: var(--cafe-text-secondary); }
81
+ .app-assist { width: 320px; padding: 12px; }
82
+ .app-assist p { margin: 0; color: var(--desktop-body); font-size: 13px; line-height: 1.5; }
83
+ .app-assist p strong { color: var(--desktop-ink); font-weight: 600; }
84
+ .app-assist > div { display: flex; justify-content: flex-end; gap: 6px; margin-top: 9px; }
85
+ .app-assist > div button { min-height: 30px; padding: 5px 10px; font-size: 12px; }
86
+ #call-bubble { width: 320px; max-height: 80px; overflow: hidden; padding: 9px 11px; pointer-events: none; }
87
+ #call-bubble:not(:has(p:not([hidden]))) { display: none; }
88
+ #call-bubble p { margin: 0; display: flex; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 12px; line-height: 1.45; }
89
+ #call-bubble p + p { margin-top: 3px; }
90
+ #call-bubble p[data-role="user"] { color: var(--desktop-muted); }
81
91
  .floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; }
82
92
  .floating button:hover:not(:disabled) { background: var(--cafe-surface); }
83
93
  .floating .primary { background: var(--desktop-primary); color: white; }
@@ -85,24 +95,22 @@ button:disabled { opacity: .45; cursor: default; }
85
95
  .floating .destructive { background: var(--desktop-danger); color: white; font-weight: 500; }
86
96
  .floating .destructive:hover:not(:disabled) { background: #942c24; }
87
97
  .floating .destructive.compact { min-width: 72px; padding: 6px 12px; }
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; }
98
+ #actions { width: 412px; overflow: hidden; padding: 10px; }
99
+ #actions[data-presentation="lite"] { width: max-content; max-width: 320px; border: 0; background: transparent; box-shadow: none; padding: 0; overflow: visible; }
100
+ #actions[data-presentation="lite"] > :not(.call-header) { display: none !important; }
101
+ #actions[data-presentation="lite"] .call-header { 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; }
102
+ #actions[data-presentation="lite"] .call-header > :not(.call-status-line) { display: none !important; }
92
103
  #actions[data-presentation="lite"] .call-status-line strong { color: inherit; font-size: 12px; }
93
104
  #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; }
105
+ .call-header { min-width: 0; min-height: 24px; display: flex; align-items: center; gap: 7px; }
98
106
  .action-row { display: flex; align-items: center; gap: 4px; }
99
- .action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
107
+ .action-row button { display: flex; align-items: center; justify-content: center; gap: 5px; min-height: 28px; height: 28px; padding: 4px 8px; font-size: 12px; white-space: nowrap; }
100
108
  .action-row button .icon { width: 16px; height: 16px; }
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; }
109
+ #active-actions { margin-top: 6px; }
110
+ #lite-call-compose { display: none; align-items: center; gap: 7px; margin-top: 6px; }
103
111
  #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; }
112
+ #lite-call-message { min-width: 0; height: 30px; flex: 1; border: 1px solid var(--desktop-line-strong); border-radius: 7px; background: var(--desktop-paper); color: var(--desktop-ink); padding: 5px 9px; }
113
+ #lite-call-compose button { height: 30px; min-height: 30px; padding: 5px 11px; }
106
114
  #status { font-size: 12px; line-height: 1.45; margin: 8px 0 0; color: var(--desktop-muted); }
107
115
  .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
108
116
  .call-status-line strong { font-size: 13px; font-weight: 600; }
@@ -139,6 +147,10 @@ button:disabled { opacity: .45; cursor: default; }
139
147
  to { opacity: 0; transform: scale(3); }
140
148
  }
141
149
  .call-meeting { min-width: 0; margin: 3px 0 0 15px; display: flex; align-items: center; gap: 5px; color: var(--desktop-muted); font-size: 12px; line-height: 1.35; }
150
+ #actions .call-meeting { margin: 0; overflow: hidden; flex: 0 1 auto; }
151
+ #actions .call-work { min-width: 0; margin: 0; overflow: hidden; flex: 0 1 auto; text-overflow: ellipsis; white-space: nowrap; }
152
+ #actions .call-work::before { content: "· "; }
153
+ .call-duration { margin-left: auto; flex: none; color: var(--desktop-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
142
154
  .call-context > .call-meeting { grid-column: 1 / -1; margin: 0; }
143
155
  .meeting-status-dot { width: 7px; height: 7px; flex: none; border-radius: 999px; background: var(--desktop-success); }
144
156
  .call-meeting[data-tone="quiet"] .meeting-status-dot { background: var(--desktop-muted); }
@@ -146,21 +158,19 @@ button:disabled { opacity: .45; cursor: default; }
146
158
  .meeting-label { flex: none; white-space: nowrap; }
147
159
  .meeting-source { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
148
160
  .call-work { margin: 3px 0 0 15px; color: var(--desktop-muted); font-size: 12px; }
149
- .call-share-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
161
+ .call-share-context { min-width: 0; display: flex; align-items: center; gap: 5px; margin-top: 4px; color: var(--desktop-muted); white-space: nowrap; }
162
+ .call-share-context .icon { width: 14px; height: 14px; }
163
+ .call-share-context strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
+ .call-share-context small { flex: none; }
150
165
  .call-share-context > span, .call-share-line > span { display: grid; gap: 1px; min-width: 0; }
151
166
  .call-share-context strong, .call-share-line strong { overflow: hidden; color: var(--desktop-body); font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
152
167
  .call-share-context small, .call-share-line small { color: var(--desktop-muted); font-size: 11px; }
153
- .call-recent { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
154
- .call-recent-label { color: var(--desktop-muted); font-size: 11px; letter-spacing: .05em; }
155
- .call-recent p { margin: 4px 0 0; display: flex; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 12px; line-height: 1.45; }
156
168
  .recent-speaker { flex: none; }
157
169
  .recent-message { min-width: 0; flex: 1 1 auto; overflow: hidden; direction: rtl; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
158
170
  .recent-message[data-direction="rtl"] { direction: ltr; }
159
171
  .recent-message-text { unicode-bidi: isolate; }
160
- .call-recent p[data-role="user"] { color: var(--desktop-muted); }
161
172
  .call-actions { justify-content: flex-end; }
162
- .call-actions #mic .label, .call-actions [data-action="transcript"] > span { display: none; }
163
- .call-actions #end { min-width: 88px; margin-left: auto; }
173
+ .call-actions #end { min-width: 60px; margin-left: auto; }
164
174
  .action-unread { min-width: 17px; height: 17px; display: inline-grid; place-items: center; border-radius: 999px; padding: 0 5px; background: #f2dcc8; color: #7a3f2e; font-size: 10px; font-weight: 700; line-height: 1; }
165
175
  #menu { width: 340px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; padding: 8px; }
166
176
  .menu-scroll { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; }
@@ -22,6 +22,8 @@ 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
24
  import { CallControlsPresentation } from './call-controls-presentation.mjs';
25
+ import { CallDuration } from './call-duration.mjs';
26
+ import { AppAssistController } from './app-assist-controller.mjs';
25
27
  const $ = id => document.getElementById(id);
26
28
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
27
29
  const status = text => {
@@ -37,7 +39,8 @@ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
37
39
  },
38
40
  });
39
41
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
40
- const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')]);
42
+ const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')], { showSpeaker: false });
43
+ const callDuration = new CallDuration($('call-duration'));
41
44
  const meetingStatus = new MeetingStatus();
42
45
  const foldedChatUnread = new FoldedChatUnread(count => {
43
46
  $('call-chat-unread').hidden = count === 0;
@@ -51,10 +54,11 @@ const nativeTravel = new NativeWindowTravel({
51
54
  changed: value => motion.syncTravel(value),
52
55
  });
53
56
  let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
57
+ let appAssistOffer = null;
54
58
  let didMove = false, settingsView, settingsValues, decisionPanel, workView, callControlsPresentation;
55
59
  let previousControlPanel = 'none';
56
60
  // F229 stays quiet: a history ID change has no provenance for proactive text.
57
- const ambientPanel = active => active ? 'actions' : 'none';
61
+ const ambientPanel = active => active ? 'actions' : appAssistOffer ? 'app-assist' : 'none';
58
62
  const setTexts = (ids, text) => { for (const id of ids) $(id).textContent = text; };
59
63
  const setHidden = (ids, hidden) => { for (const id of ids) $(id).hidden = hidden; };
60
64
  if (!window.clowderCompanion) {
@@ -135,6 +139,19 @@ if (!window.clowderCompanion) {
135
139
  accepted: () => { motion.signal('received'); void conversationSurface.readHistory(); },
136
140
  changed: () => controls.remeasure(),
137
141
  });
142
+ const appAssist = new AppAssistController({
143
+ client,
144
+ changed: value => {
145
+ appAssistOffer = value.offer;
146
+ $('app-assist-name').textContent = appAssistOffer?.appName ?? '';
147
+ void controls.setAmbient(ambientPanel(previousPhase !== 'idle'));
148
+ },
149
+ accepted: () => {
150
+ status('提示已收起。请先点“语音通话”,连上后再点“共享画面”。');
151
+ $('menu-status').hidden = false;
152
+ void controls.show('menu');
153
+ },
154
+ });
138
155
  const screen = new ScreenShare({
139
156
  screenRequest: () => client.screenPick(), screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
140
157
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
@@ -193,7 +210,9 @@ if (!window.clowderCompanion) {
193
210
  const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
194
211
  const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
195
212
  visibleText.setCall(value.callId);
213
+ callDuration.set(value.connectedAt);
196
214
  callControlsPresentation.setActive(active);
215
+ if (value.identity?.appAssist) appAssist.consume(value.identity.appAssist);
197
216
  void controls.setAmbient(ambientPanel(active));
198
217
  $('begin').hidden = active; $('begin').disabled = !value.identity;
199
218
  $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
@@ -212,7 +231,7 @@ if (!window.clowderCompanion) {
212
231
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
213
232
  callControlsPresentation.setComposerState(value.identity, value.phase === 'connecting');
214
233
  status(value.message ?? '');
215
- $('status').hidden = !value.message || /^(点击|正在听|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
234
+ $('status').hidden = !value.message || /^(点击|正在听|麦克风已静音|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
216
235
  $('menu-status').hidden = !value.message;
217
236
  const callState = value.recovering ? '连接不稳定' : value.phase === 'connecting' ? '正在连接'
218
237
  : receiveOnly ? '只听中' : active ? '通话中' : '';
@@ -278,6 +297,8 @@ if (!window.clowderCompanion) {
278
297
  if (showFailure) void controls.show('menu');
279
298
  },
280
299
  });
300
+ $('app-assist-dismiss').onclick = () => { void appAssist.settle('dismiss'); };
301
+ $('app-assist-start').onclick = () => { void appAssist.settle('accept'); };
281
302
  conversationSurface = new ConversationSurface({
282
303
  document, client, controls, bubble, transcript,
283
304
  getCallPhase: () => conversation.phase,
@@ -344,7 +365,7 @@ if (!window.clowderCompanion) {
344
365
  const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000), workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
345
366
  void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
346
367
  window.addEventListener('beforeunload', () => {
347
- clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close();
348
- unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
368
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close(); callDuration.close();
369
+ unsubscribe(); appAssist.close(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
349
370
  });
350
371
  }