@clowder-ai/companion 0.1.0-alpha.21 → 0.1.0-alpha.25
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 +25 -3
- package/manifest.json +3 -3
- package/package.json +4 -4
- package/plugin.yaml +3 -3
- package/renderer/call-transcript-view.mjs +3 -1
- package/renderer/client.mjs +60 -1
- package/renderer/conversation-draft.mjs +304 -0
- package/renderer/conversation-history.mjs +176 -0
- package/renderer/conversation-surface.mjs +178 -0
- package/renderer/conversation.mjs +16 -2
- package/renderer/index.html +28 -6
- package/renderer/meeting-state.mjs +55 -0
- package/renderer/pet-controls.mjs +8 -2
- package/renderer/recent-bubble.mjs +19 -2
- package/renderer/reply-letter-controller.mjs +343 -0
- package/renderer/reply-letter-state.mjs +38 -0
- package/renderer/reply-letter-view.mjs +103 -0
- package/renderer/style.css +75 -8
- package/renderer/surface.mjs +49 -42
- package/renderer/transcript-view.mjs +1 -0
- package/renderer/visible-text-receipt.mjs +241 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { explainError } from './errors.mjs';
|
|
2
|
+
import { ConversationHistory } from './conversation-history.mjs';
|
|
3
|
+
import { HostConversationDraft } from './conversation-draft.mjs';
|
|
4
|
+
import { ReplyLetterController } from './reply-letter-controller.mjs';
|
|
5
|
+
import { ReplyLetterView } from './reply-letter-view.mjs';
|
|
6
|
+
|
|
7
|
+
/** Owns the chat panel's history, durable draft and reply-letter interaction. */
|
|
8
|
+
export class ConversationSurface {
|
|
9
|
+
constructor({ document, client, controls, bubble, transcript, getCallPhase,
|
|
10
|
+
onAccepted, retryLegacy, status, scheduleInterval = setInterval,
|
|
11
|
+
cancelInterval = clearInterval }) {
|
|
12
|
+
this.$ = id => document.getElementById(id);
|
|
13
|
+
Object.assign(this, { client, controls, bubble, transcript, getCallPhase,
|
|
14
|
+
onAccepted, retryLegacy, status });
|
|
15
|
+
this.scheduleInterval = (...args) => Reflect.apply(scheduleInterval, globalThis, args);
|
|
16
|
+
this.cancelInterval = (...args) => Reflect.apply(cancelInterval, globalThis, args);
|
|
17
|
+
this.loading = false;
|
|
18
|
+
this.bodyReads = new Set();
|
|
19
|
+
this.replyMode = 'idle';
|
|
20
|
+
this.controller = new ReplyLetterController({
|
|
21
|
+
requestContinuation: value => void this.readBody(value.messageId, value.continuation),
|
|
22
|
+
render: value => this.renderReply(value),
|
|
23
|
+
});
|
|
24
|
+
this.view = new ReplyLetterView({
|
|
25
|
+
document,
|
|
26
|
+
controller: this.controller,
|
|
27
|
+
retry: draft => void (draft.submissionRevision === undefined
|
|
28
|
+
? this.retryLegacy(draft.text) : this.draft.retry(draft)),
|
|
29
|
+
});
|
|
30
|
+
this.lastRenderedDraft = '';
|
|
31
|
+
this.draft = new HostConversationDraft({
|
|
32
|
+
client,
|
|
33
|
+
changed: value => this.renderDraft(value),
|
|
34
|
+
delivery: value => {
|
|
35
|
+
this.controller.recordDelivery(value.receipt, value.text, value);
|
|
36
|
+
void this.readHistory();
|
|
37
|
+
},
|
|
38
|
+
invalidated: value => this.controller.invalidateDraft(value),
|
|
39
|
+
});
|
|
40
|
+
this.history = new ConversationHistory({ changed: value => this.renderHistory(value) });
|
|
41
|
+
this.bind();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
bind() {
|
|
45
|
+
this.$('message').addEventListener('input', () => this.draft.update(this.$('message').value));
|
|
46
|
+
this.$('history').onclick = () => void this.client.openConversation().then(result => {
|
|
47
|
+
if (result.delivery === 'unconfirmed') this.status('打开聊天尚未确认 · 请从 Clowder 查看');
|
|
48
|
+
}).catch(error => this.status(explainError(error)));
|
|
49
|
+
this.$('reply-history-open').addEventListener('click', () => {
|
|
50
|
+
void this.controls.show('chat');
|
|
51
|
+
void this.readHistory();
|
|
52
|
+
});
|
|
53
|
+
this.$('reply-return-latest').addEventListener('click', () => {
|
|
54
|
+
this.$('chat-transcript').scrollTop = this.$('chat-transcript').scrollHeight;
|
|
55
|
+
});
|
|
56
|
+
this.$('chat-transcript').addEventListener('scroll', () => {
|
|
57
|
+
if (this.controller.mode === 'history' && this.$('chat-transcript').scrollTop < 24) {
|
|
58
|
+
void this.readOlderHistory();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.$('compose').onsubmit = event => this.submit(event);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
renderReply(value) {
|
|
65
|
+
const previous = this.replyMode;
|
|
66
|
+
this.replyMode = value.mode;
|
|
67
|
+
this.view.render(value);
|
|
68
|
+
this.controls.remeasure?.();
|
|
69
|
+
if (value.mode === 'reply' && previous !== 'reply' && this.controls.panel !== 'chat') {
|
|
70
|
+
void this.controls.show('chat');
|
|
71
|
+
}
|
|
72
|
+
if (value.mode === 'idle' && previous === 'reply' && this.controls.panel === 'chat') {
|
|
73
|
+
void this.controls.show('none');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
renderDraft(value) {
|
|
78
|
+
const input = this.$('message');
|
|
79
|
+
if (input.value !== this.lastRenderedDraft && input.value !== value.localText) {
|
|
80
|
+
this.draft.update(input.value);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
input.value = value.localText;
|
|
84
|
+
this.lastRenderedDraft = value.localText;
|
|
85
|
+
this.controller.setDraftNotice(value.status);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
renderHistory(value) {
|
|
89
|
+
this.threadTitle = value.threadTitle || this.threadTitle;
|
|
90
|
+
if (this.threadTitle) this.$('chat-name').textContent = this.threadTitle;
|
|
91
|
+
this.bubble.load(value.messages);
|
|
92
|
+
this.transcript.load(value.messages);
|
|
93
|
+
this.controller.loadHistory(value.messages);
|
|
94
|
+
this.$('history').textContent = value.hasMoreOlder
|
|
95
|
+
? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
openWrite(callActive) {
|
|
99
|
+
if (callActive) this.controller.openHistory();
|
|
100
|
+
else this.controller.openCompose();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
closeHistory() {
|
|
104
|
+
if (this.controller.mode === 'history') this.controller.close();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
openHistory() { this.controller.openHistory(); }
|
|
108
|
+
|
|
109
|
+
async submit(event) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
this.draft.update(this.$('message').value);
|
|
112
|
+
const receipt = await this.draft.submit();
|
|
113
|
+
if (receipt?.delivery !== 'accepted') return;
|
|
114
|
+
this.onAccepted();
|
|
115
|
+
void this.readHistory();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async readHistory({ before, older = false } = {}) {
|
|
119
|
+
if (this.loading) return;
|
|
120
|
+
this.loading = true;
|
|
121
|
+
try {
|
|
122
|
+
const page = await this.client.readConversationPage(before === undefined ? {} : { before });
|
|
123
|
+
this.history.loadPage(page, { older });
|
|
124
|
+
void this.controls.setAmbient(this.getCallPhase() === 'idle' ? 'none' : 'actions');
|
|
125
|
+
this.$('chat-connection-status').textContent = page.status === 'stale'
|
|
126
|
+
? '聊天位置已变化 · 请重新往上翻' : '';
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error?.code === 'permission_required') {
|
|
129
|
+
this.history.clearProtected('permission_required');
|
|
130
|
+
this.controller.clearProtected('permission_required');
|
|
131
|
+
this.$('chat-connection-status').textContent = '聊天权限已撤销';
|
|
132
|
+
} else this.$('chat-connection-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示';
|
|
133
|
+
} finally { this.loading = false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async readOlderHistory() {
|
|
137
|
+
const { hasMoreOlder, olderCursor } = this.history.snapshot();
|
|
138
|
+
if (!hasMoreOlder || !olderCursor) return;
|
|
139
|
+
await this.readHistory({ before: olderCursor, older: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async readBody(messageId, continuation) {
|
|
143
|
+
if (this.bodyReads.has(continuation) || this.history.bodyContinuation(messageId) !== continuation) return;
|
|
144
|
+
this.bodyReads.add(continuation);
|
|
145
|
+
try {
|
|
146
|
+
const result = await this.client.readConversationBody(continuation);
|
|
147
|
+
this.history.applyBody(continuation, result);
|
|
148
|
+
if (result.status === 'stale') {
|
|
149
|
+
this.controller.releaseContinuation(messageId, continuation);
|
|
150
|
+
this.$('chat-connection-status').textContent = '回复正文已变化 · 正在重新读取';
|
|
151
|
+
void this.readHistory();
|
|
152
|
+
}
|
|
153
|
+
if (result.status === 'withdrawn') this.controller.withdrawReply(messageId);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error?.code === 'permission_required') {
|
|
156
|
+
this.history.clearProtected('permission_required');
|
|
157
|
+
this.controller.clearProtected('permission_required');
|
|
158
|
+
this.$('chat-connection-status').textContent = '聊天权限已撤销';
|
|
159
|
+
} else {
|
|
160
|
+
this.controller.releaseContinuation(messageId, continuation);
|
|
161
|
+
this.$('chat-connection-status').textContent = '完整回复暂未读完 · 稍后重试';
|
|
162
|
+
}
|
|
163
|
+
} finally { this.bodyReads.delete(continuation); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
start() {
|
|
167
|
+
void this.readHistory();
|
|
168
|
+
void this.draft.load();
|
|
169
|
+
this.historyMonitor = this.scheduleInterval(() => void this.readHistory(), 3000);
|
|
170
|
+
this.draftMonitor = this.scheduleInterval(() => void this.draft.load(), 3000);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
close() {
|
|
174
|
+
this.cancelInterval(this.historyMonitor);
|
|
175
|
+
this.cancelInterval(this.draftMonitor);
|
|
176
|
+
this.draft.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -2,8 +2,9 @@ import { explainError } from './errors.mjs';
|
|
|
2
2
|
|
|
3
3
|
/** Local controls only. Durable messages, identity and preferences belong to the Host. */
|
|
4
4
|
export class CompanionConversation {
|
|
5
|
-
constructor({ client, createPeer, render, transcript,
|
|
6
|
-
|
|
5
|
+
constructor({ client, createPeer, render, transcript, delivery = () => {}, stopScreen,
|
|
6
|
+
uuid = () => crypto.randomUUID() }) {
|
|
7
|
+
Object.assign(this, { client, createPeer, render, transcript, delivery, stopScreen, uuid });
|
|
7
8
|
this.active = false;
|
|
8
9
|
this.generation = 0;
|
|
9
10
|
this.phase = 'idle';
|
|
@@ -219,10 +220,15 @@ export class CompanionConversation {
|
|
|
219
220
|
try {
|
|
220
221
|
const receipt = await this.client.text(input.text, input.id);
|
|
221
222
|
if (receipt?.delivery !== 'accepted') {
|
|
223
|
+
this.reportDelivery(input, receipt ?? {
|
|
224
|
+
kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
|
|
225
|
+
messageId: null, callId: null,
|
|
226
|
+
});
|
|
222
227
|
if (generation === this.generation) this.show(explainError({ code: 'unconfirmed' }));
|
|
223
228
|
return false;
|
|
224
229
|
}
|
|
225
230
|
if (this.pendingText === input) this.pendingText = undefined;
|
|
231
|
+
this.reportDelivery(input, receipt);
|
|
226
232
|
// Voice may have ended; an accepted message still retires this draft.
|
|
227
233
|
// Keep the newer voice status rather than announcing that we are listening.
|
|
228
234
|
if (generation !== this.generation) return true;
|
|
@@ -230,10 +236,18 @@ export class CompanionConversation {
|
|
|
230
236
|
this.show(this.active ? this.listening() : '已发送 · 回答会留在同一段聊天中');
|
|
231
237
|
return true;
|
|
232
238
|
} catch (error) {
|
|
239
|
+
if (error?.code === 'unconfirmed') this.reportDelivery(input, {
|
|
240
|
+
kind: 'delivery', delivery: 'unconfirmed', clientMessageId: input.id,
|
|
241
|
+
messageId: null, callId: null,
|
|
242
|
+
});
|
|
233
243
|
if (generation === this.generation) this.show(explainError(error));
|
|
234
244
|
return false;
|
|
235
245
|
} finally { this.sending = false; }
|
|
236
246
|
}
|
|
247
|
+
reportDelivery(input, receipt) {
|
|
248
|
+
try { this.delivery({ text: input.text, receipt }); }
|
|
249
|
+
catch { /* Presentation failures cannot change Host delivery truth. */ }
|
|
250
|
+
}
|
|
237
251
|
muteMic() {
|
|
238
252
|
if (this.audioMode === 'receive_only') return;
|
|
239
253
|
this.muted = !this.muted; this.peer?.muteMic(this.muted);
|
package/renderer/index.html
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
</section>
|
|
50
50
|
<section id="actions" class="floating call-bar" aria-label="当前通话" hidden>
|
|
51
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>
|
|
52
53
|
<p id="call-work" class="call-work" hidden></p>
|
|
53
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>
|
|
54
55
|
<div class="call-recent" aria-label="最新消息">
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
<section id="menu" class="floating name-card" aria-label="猫猫名片" hidden>
|
|
68
69
|
<div id="menu-call-context" class="call-context" hidden>
|
|
69
70
|
<span class="status-dot" aria-hidden="true"></span><strong id="menu-call-state"></strong><span id="menu-call-mic-state"></span>
|
|
71
|
+
<small id="menu-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="menu-call-meeting-label" class="meeting-label"></span><span id="menu-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
70
72
|
<small id="menu-call-work" hidden></small>
|
|
71
73
|
<div id="menu-call-actions" class="call-context-actions menu-call-actions">
|
|
72
74
|
<button id="menu-mic" data-action="mute" aria-label="静音"><svg class="icon" aria-hidden="true"><use id="menu-mic-icon" href="#i-mic"/></svg></button>
|
|
@@ -111,19 +113,38 @@
|
|
|
111
113
|
<section id="chat" class="floating" aria-label="和猫猫写几句" hidden>
|
|
112
114
|
<div id="chat-call-context" class="call-context" hidden>
|
|
113
115
|
<span class="status-dot" aria-hidden="true"></span><strong id="chat-call-state"></strong><span id="chat-call-mic-state"></span>
|
|
116
|
+
<small id="chat-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="chat-call-meeting-label" class="meeting-label"></span><span id="chat-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
114
117
|
<small id="chat-call-work" hidden></small>
|
|
115
|
-
<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>
|
|
118
|
+
<div class="call-context-actions call-context-inline-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>
|
|
116
119
|
<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>
|
|
117
120
|
</div>
|
|
118
|
-
<header><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
121
|
+
<header id="chat-history-header"><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
|
|
119
122
|
<div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
+
<button id="reply-return-latest" class="reply-return-latest" type="button" hidden>回到最新 ↓</button>
|
|
124
|
+
<div id="reply-sent" class="reply-sent" tabindex="-1" hidden>
|
|
125
|
+
<svg viewBox="0 0 36 27" aria-hidden="true"><rect x="1" y="1" width="34" height="25" rx="4"/><path d="M2.5 3.5 18 15 33.5 3.5"/></svg>
|
|
126
|
+
<span><strong id="reply-sent-status">已发送</strong><small id="reply-letter-text"></small></span>
|
|
127
|
+
</div>
|
|
128
|
+
<article id="reply-card" class="reply-card" hidden tabindex="-1">
|
|
129
|
+
<div class="reply-head"><span id="reply-input-text" class="reply-input-text"></span>
|
|
130
|
+
<button id="reply-retain" class="reply-ring" type="button" aria-label="留住回复" title="留住回复">
|
|
131
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><circle class="reply-ring-track" cx="8" cy="8" r="6.5"/><circle class="reply-ring-progress" cx="8" cy="8" r="6.5"/></svg>
|
|
132
|
+
</button>
|
|
133
|
+
<button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>×</button>
|
|
134
|
+
</div>
|
|
135
|
+
<div id="reply-body" class="reply-body"></div>
|
|
136
|
+
<small id="reply-body-status" class="reply-body-status" role="status"></small>
|
|
137
|
+
<button id="reply-history-open" class="reply-history-open" type="button">往上翻聊天记录</button>
|
|
138
|
+
</article>
|
|
139
|
+
<form id="compose"><textarea id="message" aria-label="给猫猫输入消息" placeholder="输入消息…" rows="1" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
|
|
140
|
+
<div class="chat-feedback"><small id="chat-status" role="status"></small><button id="reply-retry" type="button" hidden>↻ 重试</button></div>
|
|
141
|
+
<small id="chat-connection-status" role="status"></small>
|
|
142
|
+
<small id="chat-scope"><span id="connection-scope">语音与文字共用同一段聊天</span> <button id="history">在 Cat Café 中查看完整聊天 ↗</button></small>
|
|
123
143
|
</section>
|
|
124
144
|
<section id="transcript" class="floating call-transcript" data-expanded="false" aria-label="这次通话的字幕" hidden>
|
|
125
145
|
<div id="transcript-call-context" class="call-context transcript-call-context">
|
|
126
146
|
<span class="status-dot" aria-hidden="true"></span><strong id="transcript-call-state"></strong><span id="transcript-call-mic-state"></span>
|
|
147
|
+
<small id="transcript-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="transcript-call-meeting-label" class="meeting-label"></span><span id="transcript-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
127
148
|
<small id="transcript-call-work" hidden></small>
|
|
128
149
|
<div class="call-context-actions">
|
|
129
150
|
<button id="transcript-return" class="transcript-return" type="button" data-action="transcript-return"><span aria-hidden="true">‹</span><span>返回通话</span></button>
|
|
@@ -142,8 +163,9 @@
|
|
|
142
163
|
<section id="settings" class="floating settings-panel" aria-label="猫猫球设置" hidden>
|
|
143
164
|
<div id="settings-call-context" class="call-context" hidden>
|
|
144
165
|
<span class="status-dot" aria-hidden="true"></span><strong id="settings-call-state"></strong><span id="settings-call-mic-state"></span>
|
|
166
|
+
<small id="settings-call-meeting" class="call-meeting" hidden><span class="meeting-status-dot" aria-hidden="true"></span><span id="settings-call-meeting-label" class="meeting-label"></span><span id="settings-call-meeting-source" class="meeting-source" hidden></span></small>
|
|
145
167
|
<small id="settings-call-work" hidden></small>
|
|
146
|
-
<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>
|
|
168
|
+
<div class="call-context-actions call-context-inline-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>
|
|
147
169
|
<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>
|
|
148
170
|
</div>
|
|
149
171
|
<header class="settings-head"><button id="settings-back" type="button" aria-label="返回名片">‹</button><strong id="settings-title">设置</strong></header>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function meetingPresentation(meeting) {
|
|
2
|
+
if (!meeting || meeting.kind === 'idle') return null;
|
|
3
|
+
if (meeting.kind === 'running') {
|
|
4
|
+
if (meeting.paused) return { label: '会议记录已暂停', sourceLabel: meeting.sourceLabel, tone: 'quiet' };
|
|
5
|
+
return meeting.sharing
|
|
6
|
+
? { label: '听会中', sourceLabel: meeting.sourceLabel, tone: 'active' }
|
|
7
|
+
: { label: '会议记录中 · 未接入通话', sourceLabel: meeting.sourceLabel, tone: 'active' };
|
|
8
|
+
}
|
|
9
|
+
if (meeting.kind === 'stopped') return { label: '会议记录已停止', sourceLabel: null, tone: 'quiet' };
|
|
10
|
+
if (meeting.kind === 'unconfirmed') return { label: '会议记录状态未确认', sourceLabel: null, tone: 'warning' };
|
|
11
|
+
if (meeting.kind === 'needs_source') return { label: '需要确认会议所在的 App', sourceLabel: null, tone: 'warning' };
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const meetingPrefixes = ['call', 'menu-call', 'chat-call', 'transcript-call', 'settings-call'];
|
|
16
|
+
export function renderMeetingStatus(document, presentation) {
|
|
17
|
+
for (const prefix of meetingPrefixes) {
|
|
18
|
+
const row = document.getElementById(`${prefix}-meeting`);
|
|
19
|
+
const source = document.getElementById(`${prefix}-meeting-source`);
|
|
20
|
+
row.hidden = presentation === null;
|
|
21
|
+
row.dataset.tone = presentation?.tone ?? '';
|
|
22
|
+
document.getElementById(`${prefix}-meeting-label`).textContent = presentation?.label ?? '';
|
|
23
|
+
source.textContent = presentation?.sourceLabel ? ` · ${presentation.sourceLabel}` : '';
|
|
24
|
+
source.hidden = !presentation?.sourceLabel;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Keeps the stopped acknowledgement transient without hiding uncertain or active capture truth. */
|
|
29
|
+
export class MeetingStatus {
|
|
30
|
+
constructor({ now = () => Date.now() } = {}) {
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.previousKind = null;
|
|
33
|
+
this.stoppedUntil = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
update(meeting) {
|
|
37
|
+
const kind = meeting?.kind ?? 'idle';
|
|
38
|
+
if (kind === 'idle') {
|
|
39
|
+
this.previousKind = 'idle';
|
|
40
|
+
this.stoppedUntil = 0;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (kind === 'stopped') {
|
|
44
|
+
if (this.previousKind !== 'stopped') {
|
|
45
|
+
this.stoppedUntil = this.previousKind !== null && this.previousKind !== 'idle'
|
|
46
|
+
? this.now() + 10_000 : 0;
|
|
47
|
+
}
|
|
48
|
+
this.previousKind = 'stopped';
|
|
49
|
+
return this.now() < this.stoppedUntil ? meetingPresentation(meeting) : null;
|
|
50
|
+
}
|
|
51
|
+
this.previousKind = kind;
|
|
52
|
+
this.stoppedUntil = 0;
|
|
53
|
+
return meetingPresentation(meeting);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -35,7 +35,13 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
|
|
|
35
35
|
$('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
|
|
36
36
|
if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
|
|
37
37
|
if (previousPanel !== next) {
|
|
38
|
-
if (next === 'chat')
|
|
38
|
+
if (next === 'chat') {
|
|
39
|
+
const mode = $('chat').dataset.replyMode;
|
|
40
|
+
const target = mode === 'reply'
|
|
41
|
+
? ($('reply-close')?.hidden === false ? $('reply-close') : $('reply-retain'))
|
|
42
|
+
: mode === 'sent' ? $('reply-sent') : $('message');
|
|
43
|
+
target?.focus?.({ preventScroll: true });
|
|
44
|
+
}
|
|
39
45
|
if (next === 'transcript') $('call-message').focus({ preventScroll: true });
|
|
40
46
|
if (next === 'settings') $('settings-back').focus({ preventScroll: true });
|
|
41
47
|
if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
|
|
@@ -98,7 +104,7 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
|
|
|
98
104
|
});
|
|
99
105
|
document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
|
|
100
106
|
const kind = button.dataset.action;
|
|
101
|
-
if (kind === 'write' || kind === 'history') { void show('chat'); return; }
|
|
107
|
+
if (kind === 'write' || kind === 'history') { action(kind); void show('chat'); return; }
|
|
102
108
|
if (kind === 'transcript') { void show('transcript'); return; }
|
|
103
109
|
if (kind === 'settings') { void show('settings'); return; }
|
|
104
110
|
if (kind === 'decisions') { void show('decisions'); return; }
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/** A compact projection of Host history plus the current unsaved voice caption. */
|
|
2
|
+
function referenceKey(reference) {
|
|
3
|
+
if (reference?.kind === 'message') return `message:${reference.messageId}`;
|
|
4
|
+
if (reference?.kind === 'native-item') return `native-item:${reference.nativeItemId}`;
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
export class RecentBubble {
|
|
3
9
|
constructor(rows) {
|
|
4
10
|
this.rows = rows;
|
|
5
11
|
this.history = [];
|
|
6
12
|
this.live = new Map();
|
|
13
|
+
this.rowReferences = new WeakMap();
|
|
7
14
|
}
|
|
8
15
|
load(messages) {
|
|
9
16
|
this.history = messages.slice(-2);
|
|
@@ -14,13 +21,16 @@ export class RecentBubble {
|
|
|
14
21
|
role: row.role,
|
|
15
22
|
text: row.text,
|
|
16
23
|
name: row.source?.kind === 'typed' ? '你' : row.role === 'assistant' ? companionName : '语音',
|
|
24
|
+
reference: { kind: 'message', messageId: row.messageId },
|
|
17
25
|
})));
|
|
18
26
|
}
|
|
19
|
-
append(role, text, name = role === 'user' ? '你' : '猫猫') {
|
|
27
|
+
append(role, text, name = role === 'user' ? '你' : '猫猫', reference) {
|
|
20
28
|
if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
|
|
21
29
|
const previous = this.live.get(role);
|
|
22
|
-
this.live.set(role, { name, text: `${previous?.text ?? ''}${text}`.slice(-240)
|
|
30
|
+
this.live.set(role, { name, text: `${previous?.text ?? ''}${text}`.slice(-240),
|
|
31
|
+
reference: reference ?? previous?.reference });
|
|
23
32
|
this.render();
|
|
33
|
+
return reference ? this.rowsForReference(reference)[0] : this.rows.find(row => row.dataset?.role === role);
|
|
24
34
|
}
|
|
25
35
|
finish(role) {
|
|
26
36
|
this.live.delete(role);
|
|
@@ -38,6 +48,10 @@ export class RecentBubble {
|
|
|
38
48
|
hasContent() {
|
|
39
49
|
return this.history.length > 0 || this.live.size > 0;
|
|
40
50
|
}
|
|
51
|
+
rowsForReference(reference) {
|
|
52
|
+
const key = referenceKey(reference);
|
|
53
|
+
return key ? this.rows.filter(row => this.rowReferences.get(row) === key) : [];
|
|
54
|
+
}
|
|
41
55
|
render() {
|
|
42
56
|
const live = [...this.live].map(([role, value]) => ({ role, ...value }));
|
|
43
57
|
const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
|
|
@@ -69,6 +83,9 @@ export class RecentBubble {
|
|
|
69
83
|
}
|
|
70
84
|
row.dataset ??= {};
|
|
71
85
|
row.dataset.role = entry?.role ?? '';
|
|
86
|
+
const key = referenceKey(entry?.reference);
|
|
87
|
+
if (key) this.rowReferences.set(row, key);
|
|
88
|
+
else this.rowReferences.delete(row);
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
}
|