@clowder-ai/companion 0.1.0-alpha.24 → 0.1.0-alpha.26

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.
@@ -0,0 +1,103 @@
1
+ const phaseLabel = phase => ({
2
+ processing: '正在回复',
3
+ failed: '回复失败 · 已发送的消息仍在聊天中',
4
+ interrupted: '回复中断 · 已发送的消息仍在聊天中',
5
+ canceled: '回复已取消 · 已发送的消息仍在聊天中',
6
+ withdrawn: '回复已撤回 · 已发送的消息仍在聊天中',
7
+ }[phase] ?? '已发送');
8
+
9
+ const draftStatus = settlement => ({
10
+ unconfirmed: '发送结果未确认',
11
+ scope_changed: '原聊天已变化 · 这条消息没有重发',
12
+ pending_limit: '待确认消息已达 32 条 · 这条没有发送',
13
+ draft_changed: '草稿已在别处变化 · 请核对后再发送',
14
+ unsupported_draft: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
15
+ conflict: '草稿已在别处变化 · 本地输入仍保留',
16
+ readonly: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
17
+ permission_required: '聊天权限已撤销 · 本地输入仍保留',
18
+ unavailable: '草稿暂未同步 · 本地输入仍保留',
19
+ }[settlement] ?? '');
20
+
21
+ /** DOM projection for compose → sent letter → complete reply → retained history. */
22
+ export class ReplyLetterView {
23
+ constructor({ document, controller, retry, schedule = setTimeout, cancel = clearTimeout }) {
24
+ this.$ = id => document.getElementById(id);
25
+ Object.assign(this, { controller, retry });
26
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
27
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
28
+ this.bind();
29
+ }
30
+
31
+ bind() {
32
+ const card = this.$('reply-card');
33
+ card.addEventListener('pointerenter', () => this.controller.pause('hover'));
34
+ card.addEventListener('pointerleave', () => this.controller.resume('hover'));
35
+ card.addEventListener('focusin', () => this.controller.pause('focus'));
36
+ card.addEventListener('focusout', event => {
37
+ if (!event.relatedTarget || !card.contains?.(event.relatedTarget)) this.controller.resume('focus');
38
+ });
39
+ const pauseScroll = () => {
40
+ this.controller.pause('scroll');
41
+ if (this.scrollTimer !== undefined) this.cancel(this.scrollTimer);
42
+ this.scrollTimer = this.schedule(() => {
43
+ this.scrollTimer = undefined;
44
+ this.controller.resume('scroll');
45
+ }, 500);
46
+ this.scrollTimer?.unref?.();
47
+ };
48
+ card.addEventListener('wheel', pauseScroll);
49
+ card.addEventListener('scroll', pauseScroll);
50
+ card.addEventListener('pointerdown', () => this.controller.pause('pointer'));
51
+ card.addEventListener('pointerup', () => this.controller.resume('pointer'));
52
+ card.addEventListener('pointercancel', () => this.controller.resume('pointer'));
53
+ this.$('reply-retain').onclick = () => this.controller.pin();
54
+ this.$('reply-close').onclick = () => this.controller.close();
55
+ this.$('reply-history-open').onclick = () => this.controller.openHistory();
56
+ this.$('reply-return-latest').onclick = () => this.controller.returnLatest();
57
+ this.$('reply-retry').onclick = () => { if (this.value?.draft) this.retry(this.value.draft); };
58
+ }
59
+
60
+ render(value) {
61
+ this.value = value;
62
+ const compose = value.mode === 'compose' || value.mode === 'history';
63
+ const history = value.mode === 'history';
64
+ this.$('compose').hidden = !compose;
65
+ this.$('chat-history-header').hidden = !history;
66
+ this.$('chat-transcript').hidden = !history;
67
+ this.$('chat-scope').hidden = !history;
68
+ this.$('reply-sent').hidden = value.mode !== 'sent';
69
+ this.$('reply-card').hidden = value.mode !== 'reply';
70
+ this.$('reply-return-latest').hidden = !history || !value.hasNewer;
71
+
72
+ const retrying = value.mode === 'compose' && value.draft?.settlement === 'unconfirmed';
73
+ this.$('reply-retry').hidden = !retrying;
74
+ this.$('chat-status').textContent = value.draft ? draftStatus(value.draft.settlement)
75
+ : value.draftNotice ? draftStatus(value.draftNotice)
76
+ : value.blockedReason === 'permission_required' ? '聊天权限已撤销' : '';
77
+ if (value.draft) {
78
+ const input = this.$('message');
79
+ if (!input.value || input.value === this.lastRenderedDraft) input.value = value.draft.text;
80
+ this.lastRenderedDraft = value.draft.text;
81
+ }
82
+
83
+ if (value.letter) {
84
+ this.$('reply-letter-text').textContent = value.letter.text;
85
+ this.$('reply-input-text').textContent = value.letter.text;
86
+ this.$('reply-sent-status').textContent = phaseLabel(value.letter.responsePhase);
87
+ }
88
+ if (value.reply) {
89
+ this.$('reply-body').textContent = value.reply.text;
90
+ this.$('reply-body-status').textContent = value.reply.complete ? '' : '正在载入完整回复…';
91
+ } else {
92
+ this.$('reply-body').textContent = '';
93
+ this.$('reply-body-status').textContent = '';
94
+ }
95
+
96
+ const canRetain = value.mode === 'reply' && value.reply?.complete === true && !value.retained;
97
+ this.$('reply-retain').hidden = !canRetain;
98
+ this.$('reply-close').hidden = value.mode !== 'reply' || !value.retained;
99
+ this.$('reply-retain').dataset.running = String(canRetain && value.timer.running);
100
+ this.$('reply-retain').style.setProperty('--reply-visible-ms', `${value.timer.remainingMs}ms`);
101
+ this.$('chat').dataset.replyMode = value.mode;
102
+ }
103
+ }
@@ -187,17 +187,47 @@ button:disabled { opacity: .45; cursor: default; }
187
187
  .work-overview-row span { color: var(--desktop-muted); font-size: 10px; }
188
188
  #work-notice { margin: 0; padding: 3px 11px 7px; color: var(--desktop-muted); font-size: 10px; }
189
189
  #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
190
- #chat header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
191
- #chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
190
+ #chat[data-reply-mode="compose"] { width: 340px; }
191
+ #chat[data-reply-mode="sent"] { width: 210px; background: transparent; box-shadow: none; }
192
+ #chat[data-reply-mode="reply"] { width: 370px; }
193
+ #chat-history-header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
194
+ #chat-history-header button { font-size: 12px; color: var(--cafe-text-secondary); }
192
195
  #chat-transcript { height: 230px; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
193
196
  #chat-transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
194
197
  #chat-transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
195
198
  #chat-transcript p.user { margin-left: auto; background: var(--desktop-rail); }
196
199
  #chat-transcript p.assistant { margin-right: auto; background: var(--desktop-work); border: 1px solid var(--desktop-line); }
197
200
  .empty { color: var(--cafe-text-muted); font-size: 13px; }
198
- #compose { display: flex; align-items: flex-end; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
199
- #message { resize: none; border: 0; min-width: 0; flex: 1; background: transparent; padding: 5px; color: inherit; line-height: 1.5; }
200
- #chat small { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
201
+ #compose { display: flex; align-items: center; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
202
+ #chat[data-reply-mode="compose"] #compose { border: 0; }
203
+ #message { resize: none; border: 1px solid var(--desktop-line-strong); border-radius: 8px; min-width: 0; height: 34px; flex: 1; background: var(--desktop-paper); padding: 6px 9px; color: inherit; line-height: 1.5; }
204
+ .chat-feedback { min-height: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; color: #8f4730; }
205
+ .chat-feedback small { flex: 1; font-size: 11px; }
206
+ .chat-feedback button { color: #8f4730; font-size: 11px; }
207
+ #chat-connection-status, #chat-scope { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
208
+ .reply-sent { align-items: center; gap: 9px; border-radius: 10px; background: var(--desktop-paper); box-shadow: 0 8px 24px #18171524; padding: 9px 11px; }
209
+ .reply-sent:not([hidden]) { display: flex; }
210
+ .reply-sent > svg { width: 36px; height: 27px; flex: none; overflow: visible; }
211
+ .reply-sent > svg rect { fill: var(--desktop-paper); stroke: var(--desktop-ink); stroke-width: 1.5; }
212
+ .reply-sent > svg path { fill: none; stroke: var(--desktop-ink); stroke-width: 1.5; stroke-linejoin: round; }
213
+ .reply-sent > span { min-width: 0; display: grid; gap: 2px; }
214
+ .reply-sent strong { font-size: 12px; font-weight: 600; }
215
+ .reply-sent small { overflow: hidden; color: var(--desktop-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
216
+ .reply-card { position: relative; max-height: 500px; overflow-y: auto; padding: 12px 14px 10px; }
217
+ .reply-head { display: flex; align-items: flex-start; gap: 8px; }
218
+ .reply-input-text { min-width: 0; flex: 1; color: var(--desktop-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
219
+ .reply-ring, .reply-close { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; color: var(--desktop-muted); }
220
+ .reply-ring svg { width: 18px; height: 18px; transform: rotate(-90deg); }
221
+ .reply-ring circle { fill: none; stroke-width: 1.5; }
222
+ .reply-ring-track { stroke: var(--desktop-line-strong); }
223
+ .reply-ring-progress { stroke: var(--desktop-ink); stroke-linecap: round; stroke-dasharray: 40.84; stroke-dashoffset: 0; }
224
+ .reply-ring[data-running="true"] .reply-ring-progress { animation: reply-countdown var(--reply-visible-ms) linear forwards; }
225
+ .reply-close { font-size: 20px; line-height: 1; }
226
+ .reply-body { margin-top: 7px; color: var(--desktop-ink); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
227
+ .reply-body-status { display: block; margin-top: 6px; color: var(--desktop-muted); font-size: 11px; }
228
+ .reply-history-open { margin-top: 8px; padding: 3px 0; color: var(--desktop-muted); font-size: 11px; }
229
+ .reply-return-latest { position: absolute; right: 12px; bottom: 59px; z-index: 1; border: 1px solid var(--desktop-line) !important; background: var(--desktop-paper) !important; box-shadow: 0 2px 8px #1817151a; color: #8f4730; font-size: 11px; }
230
+ @keyframes reply-countdown { to { stroke-dashoffset: 40.84; } }
201
231
  .call-transcript { width: 420px; height: 210px; display: flex; flex-direction: column; overflow: hidden; }
202
232
  .call-transcript[data-expanded="true"] { height: 500px; }
203
233
  .transcript-call-context { flex: none; grid-template-columns: auto minmax(0, 1fr) auto; gap: 5px; padding: 7px 8px; }
@@ -275,7 +305,7 @@ button:disabled { opacity: .45; cursor: default; }
275
305
  .settings-empty { padding: 16px; }
276
306
  .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
277
307
  @media (prefers-reduced-motion: reduce) {
278
- #pet, #indicators button, [data-connection-state] .status-dot::after, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
308
+ #pet, #indicators button, [data-connection-state] .status-dot::after, .reply-ring[data-running="true"] .reply-ring-progress, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
279
309
  #living-sit [data-layer="closed"] { opacity: 0; }
280
310
  }
281
311
  #pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
@@ -20,12 +20,12 @@ import { COMPACT_BALL_SIZE, compactSizeAction } from './compact-size.mjs';
20
20
  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
+ import { ConversationSurface } from './conversation-surface.mjs';
23
24
  const $ = id => document.getElementById(id);
24
25
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
25
26
  const status = text => {
26
27
  $('status').textContent = text;
27
28
  $('menu-status').textContent = text;
28
- $('chat-status').textContent = text;
29
29
  $('announcement').textContent = text;
30
30
  };
31
31
  const transcript = new TranscriptView($('chat-transcript'));
@@ -49,8 +49,8 @@ const nativeTravel = new NativeWindowTravel({
49
49
  readPosition: () => ({ x: window.screenX, y: window.screenY }),
50
50
  changed: value => motion.syncTravel(value),
51
51
  });
52
- let sharing = false, pendingScreen = false, loading = false, transcriptLoading = false, latestHistory, previousPhase = 'idle';
53
- let didMove = false, threadTitle, settingsView, settingsValues, decisionPanel, workView;
52
+ let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
53
+ let didMove = false, settingsView, settingsValues, decisionPanel, workView;
54
54
  let previousControlPanel = 'none';
55
55
  // F229 stays quiet: a history ID change has no provenance for proactive text.
56
56
  const ambientPanel = active => active ? 'actions' : 'none';
@@ -63,6 +63,7 @@ if (!window.clowderCompanion) {
63
63
  } else {
64
64
  nativeTravel.start();
65
65
  const client = createCompanionClient(window.clowderCompanion);
66
+ let conversationSurface;
66
67
  const visibleText = new VisibleTextPresentation({
67
68
  document,
68
69
  report: observation => client.observePresentation(observation),
@@ -81,16 +82,22 @@ if (!window.clowderCompanion) {
81
82
  },
82
83
  changed: panel => {
83
84
  visibleText.retry();
85
+ if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
86
+ if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
84
87
  if (panel === 'chat') foldedChatUnread.open();
85
88
  else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
86
89
  previousControlPanel = panel;
87
- if (panel === 'chat') void readHistory();
90
+ if (panel === 'chat') void conversationSurface.readHistory();
88
91
  if (panel === 'transcript') void readCallTranscript();
89
92
  if (panel === 'decisions') void decisionPanel?.read();
90
93
  if (panel === 'settings') void settingsView?.open();
91
94
  if (panel === 'menu') void workView?.load();
92
95
  },
93
96
  action(kind) {
97
+ if (kind === 'write') {
98
+ conversationSurface.openWrite(conversation.active);
99
+ }
100
+ if (kind === 'history') conversationSurface.openHistory();
94
101
  if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
95
102
  if (kind === 'listen') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin('receive_only'); void controls.show('none'); }
96
103
  if (kind === 'stop') void conversation.end();
@@ -146,13 +153,14 @@ if (!window.clowderCompanion) {
146
153
  }
147
154
  const conversation = new CompanionConversation({
148
155
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
156
+ delivery: ({ text, receipt }) => conversationSurface.controller.recordDelivery(receipt, text),
149
157
  transcript: event => {
150
158
  if (event.type === 'turn-done') {
151
159
  transcript.finish(event); callTranscript.finish(event); bubble.finish(event.role); callBubble.finish(event.role);
152
- void readHistory(); void readCallTranscript();
160
+ void conversationSurface.readHistory(); void readCallTranscript();
153
161
  } else if (event.type === 'typed') {
154
162
  callTranscript.appendTyped(event);
155
- void readHistory(); void readCallTranscript();
163
+ void conversationSurface.readHistory(); void readCallTranscript();
156
164
  } else {
157
165
  const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
158
166
  visibleText.appendLive(event, speaker);
@@ -163,7 +171,7 @@ if (!window.clowderCompanion) {
163
171
  const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
164
172
  const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
165
173
  if (previousPhase !== 'idle' && value.phase === 'idle') {
166
- transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void readHistory();
174
+ transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversationSurface.readHistory();
167
175
  foldedChatUnread.reset();
168
176
  if (controls.panel === 'transcript') void controls.show('menu');
169
177
  }
@@ -238,7 +246,8 @@ if (!window.clowderCompanion) {
238
246
  $('menu-play').hidden = identity.skin !== 'xianxian-codex';
239
247
  const companionLabel = companionIdentity
240
248
  ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
241
- $('chat-name').textContent = threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
249
+ $('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
250
+ $('message').placeholder = `发消息给${identity.displayName}…`;
242
251
  $('call-message').placeholder = `发消息给${identity.displayName}…`;
243
252
  $('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
244
253
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
@@ -256,6 +265,15 @@ if (!window.clowderCompanion) {
256
265
  if (showFailure) void controls.show('menu');
257
266
  },
258
267
  });
268
+ conversationSurface = new ConversationSurface({
269
+ document, client, controls, bubble, transcript,
270
+ getCallPhase: () => conversation.phase,
271
+ onAccepted: () => motion.signal('received'),
272
+ retryLegacy: text => conversation.send(text),
273
+ status,
274
+ scheduleInterval: setInterval,
275
+ cancelInterval: clearInterval,
276
+ });
259
277
  settingsView = new SettingsView({
260
278
  document,
261
279
  client,
@@ -289,21 +307,6 @@ if (!window.clowderCompanion) {
289
307
  motion,
290
308
  proactivePolicy: () => settingsValues?.proactivePolicy,
291
309
  });
292
- async function readHistory() {
293
- if (loading) return;
294
- loading = true;
295
- try {
296
- const history = await client.readConversation();
297
- $('history').textContent = history.hasMore ? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
298
- threadTitle = history.threadTitle;
299
- $('chat-name').textContent = threadTitle;
300
- bubble.load(history.messages);
301
- void controls.setAmbient(ambientPanel(conversation.phase !== 'idle'));
302
- const serialized = JSON.stringify(history.messages);
303
- if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
304
- } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
305
- finally { loading = false; }
306
- }
307
310
  async function readCallTranscript() {
308
311
  if (transcriptLoading || !conversation.active || !conversation.callId) return;
309
312
  transcriptLoading = true;
@@ -319,32 +322,28 @@ if (!window.clowderCompanion) {
319
322
  } finally { transcriptLoading = false; }
320
323
  }
321
324
  $('share-badge').onclick = toggleScreen;
322
- $('history').onclick = () => void client.openConversation().then(result => {
323
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
324
- }).catch(error => status(explainError(error)));
325
- async function submitMessage(event, inputId) {
326
- event.preventDefault(); const input = $(inputId); const text = input.value;
325
+ async function submitCallMessage(event) {
326
+ event.preventDefault(); const input = $('call-message'); const text = input.value;
327
327
  if (await conversation.send(text)) {
328
328
  motion.signal('received');
329
329
  if (input.value === text) input.value = '';
330
- latestHistory = undefined; void readHistory();
330
+ void conversationSurface.readHistory();
331
331
  }
332
332
  }
333
- $('compose').onsubmit = event => submitMessage(event, 'message');
334
- $('call-compose').onsubmit = event => submitMessage(event, 'call-message');
333
+ $('call-compose').onsubmit = submitCallMessage;
335
334
  $('call-unread').onclick = () => callTranscript.scrollToBottom();
336
335
  const unsubscribe = client.subscribe(event => {
337
336
  if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
338
337
  if (event.kind === 'view-dismiss') controls.dismiss();
339
338
  });
340
339
  const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
341
- const historyMonitor = setInterval(() => void readHistory(), 3000);
342
340
  const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
343
341
  const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
344
342
  const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
345
- void conversation.refresh(); void readHistory(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
343
+ void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
346
344
  window.addEventListener('beforeunload', () => {
347
- clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
345
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
346
+ conversationSurface.close();
348
347
  unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
349
348
  });
350
349
  }