@clowder-ai/companion 0.1.0-alpha.36 → 0.1.0-alpha.37
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.en.md +3 -1
- package/README.md +3 -1
- package/manifest.json +3 -3
- package/package.json +3 -3
- package/plugin.yaml +3 -3
- package/renderer/client.mjs +7 -0
- package/renderer/desk-delivery-controller.mjs +370 -0
- package/renderer/desk-delivery-view.mjs +183 -0
- package/renderer/desk-delivery.css +40 -0
- package/renderer/index.html +24 -1
- package/renderer/pet-controls.mjs +9 -4
- package/renderer/reply-letter-controller.mjs +2 -1
- package/renderer/style.css +2 -1
- package/renderer/surface.mjs +22 -3
package/README.en.md
CHANGED
|
@@ -44,8 +44,10 @@ The Host owns installation, caller context, credentials and lifecycle; saving co
|
|
|
44
44
|
|
|
45
45
|
## Compatibility and durable input
|
|
46
46
|
|
|
47
|
-
The desktop surface requires Host bridge 1.
|
|
47
|
+
The desktop surface requires Host bridge 1.7.0; an older Host refuses installation. The Host controls placement, dragging, transparent hit areas, media admission and capture. The plugin receives no raw streams or provider credentials. Hiding releases media; the ordinary Chat entry restores the cat.
|
|
48
48
|
|
|
49
49
|
Drafts are Host-owned and durable. An unknown send result is read back, never automatically resent. An explicit retry retains the original identity, content and route. A changed canonical conversation invalidates old unconfirmed text rather than replaying it into the new scope. History loading, response phase and body completeness remain separate.
|
|
50
50
|
|
|
51
|
+
Bridge 1.7 adds desk deliveries: the Host can hand things a cat prepared to the desk cat, one at a time or as a bundle when you come back. The window receives only titles, one sentence, the source conversation title and opaque references; thumbnails and the delivering cat's avatar arrive as inline image bytes from the Host. Opening, adding, ignoring and countdown expiry are settled by the Host. The countdown follows reply letters: pointing, focus or scrolling pauses it and clicking the ring keeps the bubble. A brief "Saved to the conversation" tag appears only when the Host confirms it kept the delivery.
|
|
52
|
+
|
|
51
53
|
Application assistance is off by default. Settling a prompt opens the ordinary menu without granting screen or call authority. Cat poses use current Host facts; dragging suspends autonomous movement. Reduced motion keeps poses static. Automatic play is bounded by idle and cooldown timers; pounce requires an explicit gesture by default.
|
package/README.md
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
## 桌面行为与隐私
|
|
22
22
|
|
|
23
|
-
需要桌面桥接版本 1.
|
|
23
|
+
需要桌面桥接版本 1.7.0;不兼容的宿主会在安装前拒绝。宿主控制窗口位置、拖动与透明区域点击,负责媒体授权和采集;插件不接收原始媒体流或提供方会话凭据。隐藏窗口会释放媒体资源,重新通过“聊聊”打开。
|
|
24
|
+
|
|
25
|
+
桥接 1.7 增加“桌面猫带来”:宿主可以把猫做好的东西交给桌面猫递到你面前,一次一件或回来时一组。小窗只收到标题、一句话、来源对话名和不透明引用,缩略图和送来那只猫的头像都由宿主以内联图片字节提供;打开、添加、忽略和倒计时结束都交回宿主处理。倒计时沿用回复气泡:指着、聚焦或滚动时暂停,点圆圈留住。只有宿主确认已保存时,猫旁才会短暂显示“已保存到对话”。
|
|
24
26
|
|
|
25
27
|
应用协助默认关闭,确认提示只打开普通菜单,不会自动授权屏幕或通话。角色动画受宿主当前事实驱动;拖动时暂停自主移动,减少动态效果设置保留静态姿态。自动玩耍受空闲和冷却时间约束,默认扑跃需要明确操作。
|
|
26
28
|
|
package/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.37",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"type": "desktop-window",
|
|
35
35
|
"id": "companion",
|
|
36
36
|
"role": "companion",
|
|
37
|
-
"bridgeVersion": "1.
|
|
37
|
+
"bridgeVersion": "1.7.0",
|
|
38
38
|
"surface": {
|
|
39
39
|
"entrypoint": "renderer/index.html",
|
|
40
|
-
"integrity": "sha256-
|
|
40
|
+
"integrity": "sha256-R425ilNu5hQtVtI0oFF8s+WBb+bWbDIXfUDn9y/Ve2c="
|
|
41
41
|
},
|
|
42
42
|
"presentation": {
|
|
43
43
|
"width": 300,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clowder-ai/companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.37",
|
|
4
4
|
"description": "The desktop voice body for your Clowder companion",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"prepack": "pnpm build"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@clowder-ai/plugin-contract": "0.3.0",
|
|
32
|
-
"@clowder-ai/plugin-sdk": "0.3.0-beta.
|
|
31
|
+
"@clowder-ai/plugin-contract": "0.3.3-candidate.0",
|
|
32
|
+
"@clowder-ai/plugin-sdk": "0.3.0-beta.2-candidate.9",
|
|
33
33
|
"playwright": "1.61.1"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
package/plugin.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pluginId": "official.companion",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.37",
|
|
4
4
|
"contractVersion": "0.1.0",
|
|
5
5
|
"name": "猫猫球",
|
|
6
6
|
"icon": {
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"type": "desktop-window",
|
|
35
35
|
"id": "companion",
|
|
36
36
|
"role": "companion",
|
|
37
|
-
"bridgeVersion": "1.
|
|
37
|
+
"bridgeVersion": "1.7.0",
|
|
38
38
|
"surface": {
|
|
39
39
|
"entrypoint": "renderer/index.html",
|
|
40
|
-
"integrity": "sha256-
|
|
40
|
+
"integrity": "sha256-R425ilNu5hQtVtI0oFF8s+WBb+bWbDIXfUDn9y/Ve2c="
|
|
41
41
|
},
|
|
42
42
|
"presentation": {
|
|
43
43
|
"width": 300,
|
package/renderer/client.mjs
CHANGED
|
@@ -104,6 +104,13 @@ export function createCompanionClient(bridge) {
|
|
|
104
104
|
readSettings: () => invoke({ kind: 'settings.read' }, 'settings'),
|
|
105
105
|
updateSetting: (field, value) => invoke({ kind: 'settings.update', field, value }, 'settings-update'),
|
|
106
106
|
settleAppAssist: (scopeRef, offerRef, action) => invoke({ kind: 'app-assist.settle', scopeRef, offerRef, action }, 'app-assist-settlement'),
|
|
107
|
+
/** shown, dismiss and timeout settle the whole delivery; open and add name one Host-issued item. */
|
|
108
|
+
settleDesk: (scopeRef, deliveryRef, action, itemRef) => invoke(itemRef === undefined
|
|
109
|
+
? { kind: 'desk.settle', scopeRef, deliveryRef, action }
|
|
110
|
+
: { kind: 'desk.settle', scopeRef, deliveryRef, action, itemRef }, 'desk-settlement'),
|
|
111
|
+
readDeskThumbnail: (scopeRef, deliveryRef, itemRef) => invoke({ kind: 'desk.thumbnail.read', scopeRef, deliveryRef, itemRef }, 'desk-thumbnail'),
|
|
112
|
+
/** The Host resolves whose avatar belongs to the delivery; the renderer names no cat. */
|
|
113
|
+
readDeskAvatar: (scopeRef, deliveryRef) => invoke({ kind: 'desk.avatar.read', scopeRef, deliveryRef }, 'desk-avatar'),
|
|
107
114
|
disableCompanion: () => invoke({ kind: 'companion.disable' }, 'companion-lifecycle'),
|
|
108
115
|
readConversation,
|
|
109
116
|
readConversationPage,
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { replyVisibleDuration } from './reply-letter-controller.mjs';
|
|
2
|
+
|
|
3
|
+
export const DESK_BUNDLE_MIN_MS = 12_000;
|
|
4
|
+
export const DESK_MAX_MS = 30_000;
|
|
5
|
+
export const DESK_SAVED_TAG_MS = 4_000;
|
|
6
|
+
const OUTCOMES = new Set(['settled', 'stale_delivery', 'failed']);
|
|
7
|
+
const SETTLED_ROWS = new Set(['opened', 'added', 'removed']);
|
|
8
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|
9
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
10
|
+
const REMEMBERED = 64;
|
|
11
|
+
|
|
12
|
+
/** Single: the reply-letter formula over sentence and title. Bundle: at least 12s, never above 30s. */
|
|
13
|
+
export function deskVisibleDuration(delivery) {
|
|
14
|
+
if (delivery.mode === 'single') {
|
|
15
|
+
const [item] = delivery.items;
|
|
16
|
+
return replyVisibleDuration(`${item.sentence ?? ''}${item.title}`);
|
|
17
|
+
}
|
|
18
|
+
const titles = delivery.items.map(item => item.title).join('');
|
|
19
|
+
return Math.min(DESK_MAX_MS, Math.max(DESK_BUNDLE_MIN_MS, replyVisibleDuration(titles)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** from:null means several cats brought the items, so the desk cat speaks with its own name. */
|
|
23
|
+
export function deskHeader(from, deskCatName) {
|
|
24
|
+
if (!from) return deskCatName;
|
|
25
|
+
return from.role === 'watcher' ? `守望猫 · ${from.displayName}` : from.displayName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rasterUrl = (image, maxLength) => {
|
|
29
|
+
if (!image || !IMAGE_TYPES.has(image.mediaType) || typeof image.dataBase64 !== 'string'
|
|
30
|
+
|| image.dataBase64.length > maxLength || image.dataBase64.length % 4 !== 0 || !BASE64.test(image.dataBase64)) return null;
|
|
31
|
+
return `data:${image.mediaType};base64,${image.dataBase64}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Only bounded raster bytes for the exact item become an image; everything else stays a neutral tile. */
|
|
35
|
+
export function deskThumbnailUrl(reply, { scopeRef, deliveryRef, itemRef }) {
|
|
36
|
+
return rasterUrl(reply?.kind === 'desk-thumbnail' && reply.scopeRef === scopeRef
|
|
37
|
+
&& reply.deliveryRef === deliveryRef && reply.itemRef === itemRef ? reply.image : null, 400_000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The delivering cat's avatar for this exact delivery, or null so the header keeps only its text. */
|
|
41
|
+
export function deskAvatarUrl(reply, { scopeRef, deliveryRef }) {
|
|
42
|
+
return rasterUrl(reply?.kind === 'desk-avatar' && reply.scopeRef === scopeRef
|
|
43
|
+
&& reply.deliveryRef === deliveryRef ? reply.image : null, 60_000);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const remember = (set, value) => {
|
|
47
|
+
set.delete(value);
|
|
48
|
+
set.add(value);
|
|
49
|
+
if (set.size > REMEMBERED) set.delete(set.values().next().value);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renderer projection of Host-owned desk deliveries. The Host decides what is
|
|
54
|
+
* brought, where it lives and what opening or adding does; this controller only
|
|
55
|
+
* fences stale state, keeps the reply-letter pause/retain countdown while the
|
|
56
|
+
* bubble is actually visible, and returns opaque references with one action.
|
|
57
|
+
*/
|
|
58
|
+
export class DeskDeliveryController {
|
|
59
|
+
constructor({ client, render = () => {}, now = Date.now, schedule = setTimeout, cancel = clearTimeout,
|
|
60
|
+
savedTagMs = DESK_SAVED_TAG_MS }) {
|
|
61
|
+
Object.assign(this, { client, render, now, savedTagMs });
|
|
62
|
+
this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
|
|
63
|
+
this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
|
|
64
|
+
this.scopeRef = null;
|
|
65
|
+
this.revision = -1;
|
|
66
|
+
this.current = null;
|
|
67
|
+
this.epoch = 0;
|
|
68
|
+
this.visible = false;
|
|
69
|
+
this.retained = false;
|
|
70
|
+
this.pauseReasons = new Set();
|
|
71
|
+
this.timerHandle = null;
|
|
72
|
+
this.timerStartedAt = null;
|
|
73
|
+
this.remainingMs = 0;
|
|
74
|
+
this.totalMs = 0;
|
|
75
|
+
this.tag = null;
|
|
76
|
+
this.tagHandle = null;
|
|
77
|
+
this.shown = new Set();
|
|
78
|
+
this.finished = new Set();
|
|
79
|
+
this.closed = false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
consume(snapshot) {
|
|
83
|
+
if (this.closed || !snapshot || typeof snapshot.scopeRef !== 'string' || !Number.isSafeInteger(snapshot.revision)) return false;
|
|
84
|
+
if (this.scopeRef !== null && snapshot.scopeRef !== this.scopeRef) return false;
|
|
85
|
+
if (snapshot.revision <= this.revision) return false;
|
|
86
|
+
this.scopeRef = snapshot.scopeRef;
|
|
87
|
+
this.revision = snapshot.revision;
|
|
88
|
+
const delivery = snapshot.status === 'ready' ? snapshot.delivery : null;
|
|
89
|
+
const presented = Boolean(delivery) && !this.finished.has(delivery.deliveryRef)
|
|
90
|
+
&& this.current?.deliveryRef !== delivery.deliveryRef;
|
|
91
|
+
if (!delivery || this.finished.has(delivery.deliveryRef)) this.drop();
|
|
92
|
+
else if (presented) this.present(delivery);
|
|
93
|
+
else this.update(delivery);
|
|
94
|
+
this.emit();
|
|
95
|
+
// A replacement is reported shown only after its own content has rendered; a first
|
|
96
|
+
// delivery still waits for setVisible(), i.e. for the Host to place the panel.
|
|
97
|
+
if (presented && this.current && this.visible) {
|
|
98
|
+
this.reveal();
|
|
99
|
+
this.emit();
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
present(delivery) {
|
|
105
|
+
this.drop();
|
|
106
|
+
this.clearTag();
|
|
107
|
+
this.current = {
|
|
108
|
+
epoch: ++this.epoch, deliveryRef: delivery.deliveryRef, mode: delivery.mode, from: delivery.from,
|
|
109
|
+
more: delivery.more, rows: new Map(), avatar: null, avatarKey: null, avatarRead: null,
|
|
110
|
+
};
|
|
111
|
+
this.totalMs = deskVisibleDuration(delivery);
|
|
112
|
+
this.remainingMs = this.totalMs;
|
|
113
|
+
this.update(delivery);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
update(delivery) {
|
|
117
|
+
const current = this.current;
|
|
118
|
+
Object.assign(current, { mode: delivery.mode, from: delivery.from, more: delivery.more });
|
|
119
|
+
const incoming = new Map(delivery.items.map(item => [item.itemRef, item]));
|
|
120
|
+
const rows = new Map();
|
|
121
|
+
// Rows the person already opened or added stay as receipts until the bubble closes,
|
|
122
|
+
// even when the Host has dropped them; anything else the Host withdrew disappears.
|
|
123
|
+
for (const [itemRef, row] of current.rows) {
|
|
124
|
+
const item = incoming.get(itemRef);
|
|
125
|
+
if (item) rows.set(itemRef, { ...row, item });
|
|
126
|
+
else if (row.state === 'opened' || row.state === 'added') rows.set(itemRef, row);
|
|
127
|
+
}
|
|
128
|
+
for (const item of delivery.items) {
|
|
129
|
+
if (!rows.has(item.itemRef)) rows.set(item.itemRef, { item, state: 'idle', thumbnail: null, thumbnailRead: null });
|
|
130
|
+
}
|
|
131
|
+
current.rows = rows;
|
|
132
|
+
for (const [itemRef, row] of rows) {
|
|
133
|
+
if (!incoming.has(itemRef)) continue;
|
|
134
|
+
if (!row.item.thumbnail) rows.set(itemRef, { ...row, thumbnail: null, thumbnailRead: null });
|
|
135
|
+
else if (!row.thumbnail && this.readStale(row.thumbnailRead)) void this.loadThumbnail(current, itemRef);
|
|
136
|
+
}
|
|
137
|
+
// A different bringer never inherits the previous face; its own read starts at this revision.
|
|
138
|
+
const avatarKey = delivery.from ? `${delivery.from.catId}\u0000${delivery.from.role ?? ''}` : null;
|
|
139
|
+
if (avatarKey !== current.avatarKey) Object.assign(current, { avatarKey, avatar: null, avatarRead: null });
|
|
140
|
+
if (avatarKey && !current.avatar && this.readStale(current.avatarRead)) void this.loadAvatar(current);
|
|
141
|
+
if ([...rows.values()].every(row => SETTLED_ROWS.has(row.state))) this.finish();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A read needs (re)issuing unless one finished or is still in flight for the current revision. */
|
|
145
|
+
readStale(read) {
|
|
146
|
+
return !read || (!read.done && read.revision !== this.revision);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Replies apply only to the exact scope, delivery and Host revision they were requested at. */
|
|
150
|
+
readCurrent(current, read) {
|
|
151
|
+
return this.current === current && this.scopeRef === read.scopeRef
|
|
152
|
+
&& current.deliveryRef === read.deliveryRef && this.revision === read.revision;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setVisible(visible) {
|
|
156
|
+
if (this.visible === visible) return;
|
|
157
|
+
this.visible = visible;
|
|
158
|
+
if (visible) this.reveal();
|
|
159
|
+
else this.pauseTimer();
|
|
160
|
+
this.emit();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
reveal() {
|
|
164
|
+
const current = this.current;
|
|
165
|
+
if (!current || !this.visible) return;
|
|
166
|
+
if (!this.shown.has(current.deliveryRef)) {
|
|
167
|
+
remember(this.shown, current.deliveryRef);
|
|
168
|
+
void this.settleWhole('shown');
|
|
169
|
+
}
|
|
170
|
+
this.startTimer();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pause(reason) {
|
|
174
|
+
if (this.pauseReasons.has(reason)) return;
|
|
175
|
+
this.pauseReasons.add(reason);
|
|
176
|
+
this.pauseTimer();
|
|
177
|
+
this.emit();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resume(reason) {
|
|
181
|
+
if (!this.pauseReasons.delete(reason)) return;
|
|
182
|
+
this.startTimer();
|
|
183
|
+
this.emit();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pin() {
|
|
187
|
+
if (!this.current || this.retained) return;
|
|
188
|
+
this.retained = true;
|
|
189
|
+
this.pauseTimer();
|
|
190
|
+
this.emit();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
dismiss() { return this.settleWhole('dismiss'); }
|
|
194
|
+
|
|
195
|
+
async act(itemRef) {
|
|
196
|
+
const current = this.current;
|
|
197
|
+
const row = current?.rows.get(itemRef);
|
|
198
|
+
if (!row || !['idle', 'failed'].includes(row.state)) return false;
|
|
199
|
+
const { scopeRef, revision } = this;
|
|
200
|
+
const action = row.item.action;
|
|
201
|
+
const before = row.state;
|
|
202
|
+
this.setRow(current, itemRef, { state: 'pending' });
|
|
203
|
+
this.emit();
|
|
204
|
+
let reply = null;
|
|
205
|
+
try {
|
|
206
|
+
reply = await this.client.settleDesk(scopeRef, current.deliveryRef, action, itemRef);
|
|
207
|
+
} catch {
|
|
208
|
+
// A lost reply leaves the item actionable; the Host remains the only truth.
|
|
209
|
+
}
|
|
210
|
+
if (this.current !== current || !current.rows.has(itemRef)) return false;
|
|
211
|
+
// No reply at all reads as a failed attempt; a reply for another item or an older Host
|
|
212
|
+
// revision is ignored and the row goes back to what it was, claiming nothing.
|
|
213
|
+
const matched = this.matches(reply, { scopeRef, revision, deliveryRef: current.deliveryRef, action, itemRef });
|
|
214
|
+
const state = reply === null ? 'failed' : !matched ? before
|
|
215
|
+
: reply.outcome === 'failed' ? 'failed' : reply.outcome === 'stale_delivery' ? 'removed'
|
|
216
|
+
: action === 'add' ? 'added' : 'opened';
|
|
217
|
+
this.setRow(current, itemRef, { state });
|
|
218
|
+
if ([...current.rows.values()].every(value => SETTLED_ROWS.has(value.state))) this.finish();
|
|
219
|
+
this.emit();
|
|
220
|
+
return state === 'added' || state === 'opened';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async settleWhole(action) {
|
|
224
|
+
const current = this.current;
|
|
225
|
+
if (!current) return false;
|
|
226
|
+
const { scopeRef, revision, epoch } = this;
|
|
227
|
+
if (action !== 'shown') {
|
|
228
|
+
this.finish();
|
|
229
|
+
this.emit();
|
|
230
|
+
}
|
|
231
|
+
let reply;
|
|
232
|
+
try {
|
|
233
|
+
reply = await this.client.settleDesk(scopeRef, current.deliveryRef, action);
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (!this.matches(reply, { scopeRef, revision, deliveryRef: current.deliveryRef, action })) return false;
|
|
238
|
+
if (action === 'shown' && reply.outcome === 'stale_delivery' && this.current === current) {
|
|
239
|
+
this.finish();
|
|
240
|
+
this.emit();
|
|
241
|
+
}
|
|
242
|
+
// The Host alone decides whether an unanswered delivery was kept; never claim it otherwise.
|
|
243
|
+
if (action === 'timeout' && reply.outcome === 'settled' && reply.saved === true
|
|
244
|
+
&& this.epoch === epoch && !this.current && !this.closed) this.showTag('saved');
|
|
245
|
+
return reply.outcome === 'settled';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
matches(reply, { scopeRef, revision, deliveryRef, action, itemRef }) {
|
|
249
|
+
return reply?.kind === 'desk-settlement' && reply.scopeRef === scopeRef && reply.deliveryRef === deliveryRef
|
|
250
|
+
&& reply.action === action && reply.itemRef === itemRef && OUTCOMES.has(reply.outcome)
|
|
251
|
+
&& Number.isSafeInteger(reply.revision) && reply.revision >= revision && reply.revision >= this.revision;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async loadThumbnail(current, itemRef) {
|
|
255
|
+
const read = { scopeRef: this.scopeRef, deliveryRef: current.deliveryRef, revision: this.revision, done: false };
|
|
256
|
+
this.setRow(current, itemRef, { thumbnailRead: read });
|
|
257
|
+
let reply;
|
|
258
|
+
try {
|
|
259
|
+
reply = await this.client.readDeskThumbnail(read.scopeRef, read.deliveryRef, itemRef);
|
|
260
|
+
} catch {
|
|
261
|
+
return; // A lost read leaves the neutral tile and stays unfinished, so a later revision asks again.
|
|
262
|
+
}
|
|
263
|
+
if (!this.readCurrent(current, read) || current.rows.get(itemRef)?.thumbnailRead !== read) return;
|
|
264
|
+
read.done = true;
|
|
265
|
+
const thumbnail = deskThumbnailUrl(reply, { ...read, itemRef });
|
|
266
|
+
if (!thumbnail) return;
|
|
267
|
+
this.setRow(current, itemRef, { thumbnail });
|
|
268
|
+
this.emit();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async loadAvatar(current) {
|
|
272
|
+
const read = { scopeRef: this.scopeRef, deliveryRef: current.deliveryRef, revision: this.revision, done: false };
|
|
273
|
+
current.avatarRead = read;
|
|
274
|
+
let reply;
|
|
275
|
+
try {
|
|
276
|
+
reply = await this.client.readDeskAvatar(read.scopeRef, read.deliveryRef);
|
|
277
|
+
} catch {
|
|
278
|
+
return; // Without an answer the header keeps its text and a later revision asks again.
|
|
279
|
+
}
|
|
280
|
+
if (!this.readCurrent(current, read) || current.avatarRead !== read) return;
|
|
281
|
+
read.done = true;
|
|
282
|
+
current.avatar = deskAvatarUrl(reply, read);
|
|
283
|
+
if (current.avatar) this.emit();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setRow(current, itemRef, patch) {
|
|
287
|
+
current.rows.set(itemRef, { ...current.rows.get(itemRef), ...patch });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
finish() {
|
|
291
|
+
if (this.current) remember(this.finished, this.current.deliveryRef);
|
|
292
|
+
this.drop();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
drop() {
|
|
296
|
+
this.pauseTimer();
|
|
297
|
+
this.remainingMs = 0;
|
|
298
|
+
this.current = null;
|
|
299
|
+
this.retained = false;
|
|
300
|
+
this.pauseReasons.clear();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
startTimer() {
|
|
304
|
+
if (this.timerHandle !== null || !this.current || !this.visible || this.retained
|
|
305
|
+
|| this.pauseReasons.size > 0 || this.remainingMs <= 0) return;
|
|
306
|
+
const epoch = this.current.epoch;
|
|
307
|
+
this.timerStartedAt = this.now();
|
|
308
|
+
this.timerHandle = this.schedule(() => {
|
|
309
|
+
this.timerHandle = null;
|
|
310
|
+
this.timerStartedAt = null;
|
|
311
|
+
this.remainingMs = 0;
|
|
312
|
+
if (this.current?.epoch === epoch) void this.settleWhole('timeout');
|
|
313
|
+
}, this.remainingMs);
|
|
314
|
+
this.timerHandle?.unref?.();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
pauseTimer() {
|
|
318
|
+
if (this.timerHandle === null) return;
|
|
319
|
+
this.remainingMs = Math.max(0, this.remainingMs - (this.now() - this.timerStartedAt));
|
|
320
|
+
this.cancel(this.timerHandle);
|
|
321
|
+
this.timerHandle = null;
|
|
322
|
+
this.timerStartedAt = null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
showTag(tag) {
|
|
326
|
+
this.clearTag();
|
|
327
|
+
this.tag = tag;
|
|
328
|
+
this.tagHandle = this.schedule(() => {
|
|
329
|
+
this.tagHandle = null;
|
|
330
|
+
this.tag = null;
|
|
331
|
+
this.emit();
|
|
332
|
+
}, this.savedTagMs);
|
|
333
|
+
this.tagHandle?.unref?.();
|
|
334
|
+
this.emit();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
clearTag() {
|
|
338
|
+
if (this.tagHandle !== null) this.cancel(this.tagHandle);
|
|
339
|
+
this.tagHandle = null;
|
|
340
|
+
this.tag = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
close() {
|
|
344
|
+
this.closed = true;
|
|
345
|
+
this.drop();
|
|
346
|
+
this.clearTag();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
snapshot() {
|
|
350
|
+
const current = this.current;
|
|
351
|
+
const elapsed = this.timerHandle === null ? 0 : this.now() - this.timerStartedAt;
|
|
352
|
+
return {
|
|
353
|
+
presentation: current ? 'bubble' : this.tag ? 'tag' : 'none',
|
|
354
|
+
tag: this.tag,
|
|
355
|
+
delivery: current && {
|
|
356
|
+
deliveryRef: current.deliveryRef, mode: current.mode, from: current.from, more: current.more,
|
|
357
|
+
avatar: current.from ? current.avatar : null,
|
|
358
|
+
rows: [...current.rows.values()].map(row => ({ ...row })),
|
|
359
|
+
},
|
|
360
|
+
retained: this.retained,
|
|
361
|
+
timer: {
|
|
362
|
+
running: this.timerHandle !== null,
|
|
363
|
+
remainingMs: Math.max(0, this.remainingMs - elapsed),
|
|
364
|
+
totalMs: this.totalMs,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
emit() { this.render(this.snapshot()); }
|
|
370
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { deskHeader } from './desk-delivery-controller.mjs';
|
|
2
|
+
|
|
3
|
+
const ACTION_LABEL = { open: '打开', add: '添加' };
|
|
4
|
+
const DONE_LABEL = { opened: '已打开', added: '已添加' };
|
|
5
|
+
const FAILED_LABEL = { open: '没打开', add: '没加上' };
|
|
6
|
+
const RING_LENGTH = 40.84;
|
|
7
|
+
|
|
8
|
+
const setText = (node, text) => { if (node.textContent !== text) node.textContent = text; };
|
|
9
|
+
|
|
10
|
+
/** DOM projection of one desk delivery: text only, data: thumbnails only, actions go back to the controller. */
|
|
11
|
+
export class DeskDeliveryView {
|
|
12
|
+
constructor({ document, controller, displayName = () => '猫猫', reducedMotion = () => false,
|
|
13
|
+
schedule = setTimeout, cancel = clearTimeout }) {
|
|
14
|
+
this.document = document;
|
|
15
|
+
this.$ = id => document.getElementById(id);
|
|
16
|
+
Object.assign(this, { controller, displayName, reducedMotion });
|
|
17
|
+
this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
|
|
18
|
+
this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
|
|
19
|
+
this.rows = new Map();
|
|
20
|
+
this.rowsRef = null;
|
|
21
|
+
this.ringRef = null;
|
|
22
|
+
this.animation = null;
|
|
23
|
+
this.bind();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
bind() {
|
|
27
|
+
const bubble = this.$('desk-bubble');
|
|
28
|
+
bubble.addEventListener('pointerenter', () => this.controller.pause('hover'));
|
|
29
|
+
bubble.addEventListener('pointerleave', () => this.controller.resume('hover'));
|
|
30
|
+
bubble.addEventListener('focusin', () => this.controller.pause('focus'));
|
|
31
|
+
bubble.addEventListener('focusout', event => {
|
|
32
|
+
if (!event.relatedTarget || !bubble.contains?.(event.relatedTarget)) this.controller.resume('focus');
|
|
33
|
+
});
|
|
34
|
+
const pauseScroll = () => {
|
|
35
|
+
this.controller.pause('scroll');
|
|
36
|
+
if (this.scrollTimer !== undefined) this.cancel(this.scrollTimer);
|
|
37
|
+
this.scrollTimer = this.schedule(() => {
|
|
38
|
+
this.scrollTimer = undefined;
|
|
39
|
+
this.controller.resume('scroll');
|
|
40
|
+
}, 500);
|
|
41
|
+
this.scrollTimer?.unref?.();
|
|
42
|
+
};
|
|
43
|
+
bubble.addEventListener('wheel', pauseScroll);
|
|
44
|
+
bubble.addEventListener('scroll', pauseScroll);
|
|
45
|
+
bubble.addEventListener('pointerdown', () => this.controller.pause('pointer'));
|
|
46
|
+
bubble.addEventListener('pointerup', () => this.controller.resume('pointer'));
|
|
47
|
+
bubble.addEventListener('pointercancel', () => this.controller.resume('pointer'));
|
|
48
|
+
this.$('desk-retain').onclick = () => this.controller.pin();
|
|
49
|
+
this.$('desk-close').onclick = () => { void this.controller.dismiss(); };
|
|
50
|
+
this.$('desk-dismiss').onclick = () => { void this.controller.dismiss(); };
|
|
51
|
+
this.$('desk-open').onclick = () => {
|
|
52
|
+
const row = this.value?.delivery?.rows[0];
|
|
53
|
+
if (row) void this.controller.act(row.item.itemRef);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(value) {
|
|
58
|
+
this.value = value;
|
|
59
|
+
const root = this.$('desk');
|
|
60
|
+
const delivery = value.delivery;
|
|
61
|
+
root.dataset.presentation = value.presentation;
|
|
62
|
+
this.$('desk-bubble').hidden = !delivery;
|
|
63
|
+
this.$('desk-tag').hidden = value.presentation !== 'tag';
|
|
64
|
+
this.syncRing(delivery?.deliveryRef ?? null, value);
|
|
65
|
+
if ((delivery?.deliveryRef ?? null) !== this.rowsRef) {
|
|
66
|
+
for (const nodes of this.rows.values()) nodes.row.remove();
|
|
67
|
+
this.rows.clear();
|
|
68
|
+
this.rowsRef = delivery?.deliveryRef ?? null;
|
|
69
|
+
}
|
|
70
|
+
if (!delivery) return;
|
|
71
|
+
root.dataset.mode = delivery.mode;
|
|
72
|
+
setText(this.$('desk-name'), deskHeader(delivery.from, this.displayName()));
|
|
73
|
+
const avatar = this.$('desk-avatar');
|
|
74
|
+
avatar.hidden = !delivery.avatar;
|
|
75
|
+
const avatarImage = this.$('desk-avatar-image');
|
|
76
|
+
if (delivery.avatar && avatarImage.getAttribute('src') !== delivery.avatar) avatarImage.setAttribute('src', delivery.avatar);
|
|
77
|
+
this.$('desk-avatar-eye').hidden = delivery.from?.role !== 'watcher';
|
|
78
|
+
this.$('desk-retain').hidden = value.retained;
|
|
79
|
+
this.$('desk-close').hidden = !value.retained;
|
|
80
|
+
const rows = delivery.rows.filter(row => row.state !== 'removed');
|
|
81
|
+
const single = delivery.mode === 'single';
|
|
82
|
+
if (single && rows[0]) this.renderSingle(rows[0]);
|
|
83
|
+
this.$('desk-open').hidden = !single || !rows[0];
|
|
84
|
+
this.$('desk-list').hidden = single;
|
|
85
|
+
if (!single) this.renderBundle(rows);
|
|
86
|
+
const more = !single && delivery.more > 0;
|
|
87
|
+
this.$('desk-more').hidden = !more;
|
|
88
|
+
setText(this.$('desk-more'), more ? `还有 ${delivery.more} 件在 Cat Café` : '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
renderSingle({ item, state, thumbnail }) {
|
|
92
|
+
const text = this.$('desk-text');
|
|
93
|
+
setText(text, item.sentence ?? item.title);
|
|
94
|
+
text.hidden = false;
|
|
95
|
+
const thumb = this.$('desk-thumb');
|
|
96
|
+
thumb.hidden = !thumbnail;
|
|
97
|
+
const image = this.$('desk-thumb-image');
|
|
98
|
+
if (thumbnail && image.getAttribute('src') !== thumbnail) image.setAttribute('src', thumbnail);
|
|
99
|
+
if (image.alt !== item.title) image.alt = item.title;
|
|
100
|
+
// Without the picture the person still needs to know what 打开 would open.
|
|
101
|
+
const title = this.$('desk-title');
|
|
102
|
+
title.hidden = Boolean(thumbnail) || item.sentence === undefined;
|
|
103
|
+
setText(title, title.hidden ? '' : item.title);
|
|
104
|
+
const button = this.$('desk-open');
|
|
105
|
+
const label = ACTION_LABEL[item.action];
|
|
106
|
+
setText(button, label);
|
|
107
|
+
button.setAttribute('aria-label', `${label}「${item.title}」`);
|
|
108
|
+
button.disabled = state === 'pending';
|
|
109
|
+
const note = this.$('desk-note');
|
|
110
|
+
note.hidden = state !== 'failed';
|
|
111
|
+
setText(note, state === 'failed' ? FAILED_LABEL[item.action] : '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
renderBundle(rows) {
|
|
115
|
+
for (const id of ['desk-text', 'desk-thumb', 'desk-title', 'desk-note']) this.$(id).hidden = true;
|
|
116
|
+
const list = this.$('desk-list');
|
|
117
|
+
const keep = new Set(rows.map(row => row.item.itemRef));
|
|
118
|
+
for (const [itemRef, nodes] of this.rows) {
|
|
119
|
+
if (!keep.has(itemRef)) { nodes.row.remove(); this.rows.delete(itemRef); }
|
|
120
|
+
}
|
|
121
|
+
rows.forEach((row, index) => {
|
|
122
|
+
const nodes = this.rows.get(row.item.itemRef) ?? this.createRow(row.item.itemRef);
|
|
123
|
+
if (list.children[index] !== nodes.row) list.insertBefore(nodes.row, list.children[index] ?? null);
|
|
124
|
+
this.renderRow(nodes, row);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
createRow(itemRef) {
|
|
129
|
+
const row = this.$('desk-row-template').content.firstElementChild.cloneNode(true);
|
|
130
|
+
const nodes = {
|
|
131
|
+
row,
|
|
132
|
+
tile: row.querySelector('.desk-row-thumb'),
|
|
133
|
+
icon: row.querySelector('.desk-row-thumb use'),
|
|
134
|
+
title: row.querySelector('.desk-row-text b'),
|
|
135
|
+
source: row.querySelector('.desk-row-text small'),
|
|
136
|
+
note: row.querySelector('.desk-note'),
|
|
137
|
+
button: row.querySelector('button'),
|
|
138
|
+
image: null,
|
|
139
|
+
};
|
|
140
|
+
nodes.button.onclick = () => { void this.controller.act(itemRef); };
|
|
141
|
+
this.rows.set(itemRef, nodes);
|
|
142
|
+
return nodes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
renderRow(nodes, { item, state, thumbnail }) {
|
|
146
|
+
nodes.tile.dataset.kind = item.thumbnail ? 'image' : item.kind;
|
|
147
|
+
if (thumbnail && !nodes.image) {
|
|
148
|
+
nodes.image = this.document.createElement('img');
|
|
149
|
+
nodes.image.alt = '';
|
|
150
|
+
nodes.tile.append(nodes.image);
|
|
151
|
+
}
|
|
152
|
+
if (nodes.image && thumbnail && nodes.image.getAttribute('src') !== thumbnail) nodes.image.setAttribute('src', thumbnail);
|
|
153
|
+
nodes.icon.setAttribute('href', item.kind === 'suggestion' ? '#i-desk-note' : '#i-desk-doc');
|
|
154
|
+
setText(nodes.title, item.title);
|
|
155
|
+
const source = item.source.conversationTitle ?? '';
|
|
156
|
+
setText(nodes.source, source);
|
|
157
|
+
nodes.source.hidden = !source;
|
|
158
|
+
const label = DONE_LABEL[state] ?? ACTION_LABEL[item.action];
|
|
159
|
+
setText(nodes.button, label);
|
|
160
|
+
nodes.button.setAttribute('aria-label', `${label}「${item.title}」`);
|
|
161
|
+
nodes.button.disabled = !['idle', 'failed'].includes(state);
|
|
162
|
+
nodes.note.hidden = state !== 'failed';
|
|
163
|
+
setText(nodes.note, state === 'failed' ? FAILED_LABEL[item.action] : '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The ring mirrors the controller's own clock, so pausing, hiding and resuming never restart it. */
|
|
167
|
+
syncRing(deliveryRef, value) {
|
|
168
|
+
if (deliveryRef !== this.ringRef) {
|
|
169
|
+
this.animation?.cancel();
|
|
170
|
+
this.animation = null;
|
|
171
|
+
this.ringRef = deliveryRef;
|
|
172
|
+
}
|
|
173
|
+
const progress = this.$('desk-retain').querySelector('.reply-ring-progress');
|
|
174
|
+
if (!deliveryRef || value.retained || typeof progress?.animate !== 'function') return;
|
|
175
|
+
this.animation ??= progress.animate(
|
|
176
|
+
[{ strokeDashoffset: 0 }, { strokeDashoffset: RING_LENGTH }],
|
|
177
|
+
{ duration: value.timer.totalMs, fill: 'forwards', easing: this.reducedMotion() ? 'steps(15, end)' : 'linear' },
|
|
178
|
+
);
|
|
179
|
+
this.animation.currentTime = value.timer.totalMs - value.timer.remainingMs;
|
|
180
|
+
if (value.timer.running) this.animation.play();
|
|
181
|
+
else this.animation.pause();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* F335 desk deliveries: the reply-letter bubble (.floating, .reply-head, .reply-ring)
|
|
2
|
+
* plus only the watcher sketch's additions. Sizes and colours follow the sketch. */
|
|
3
|
+
/* The sketch draws its 1px edge as an inset shadow; .floating uses a real border, so padding is 1px less. */
|
|
4
|
+
#desk { width: 344px; padding: 9px 11px 11px; overflow: hidden; }
|
|
5
|
+
/* The sketch renders with the system face first and grayscale smoothing; without both, 13px/500 reads heavier. */
|
|
6
|
+
#desk, #desk button { font-family: -apple-system, Inter, "PingFang SC", "Hiragino Sans GB", sans-serif; -webkit-font-smoothing: antialiased; }
|
|
7
|
+
#desk[data-presentation="tag"] { width: max-content; min-width: 120px; display: flex; justify-content: center; border: 0; background: transparent; box-shadow: none; padding: 0; overflow: visible; }
|
|
8
|
+
.desk-bubble:not([hidden]) { display: flex; flex-direction: column; gap: 6px; }
|
|
9
|
+
.desk-head { min-height: 20px; }
|
|
10
|
+
.desk-who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; min-height: 20px; color: var(--desktop-body); font-size: 13px; }
|
|
11
|
+
.desk-avatar { position: relative; width: 20px; height: 20px; flex: none; }
|
|
12
|
+
.desk-avatar img { display: block; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
|
|
13
|
+
.desk-avatar-eye:not([hidden]) { position: absolute; right: -3px; bottom: -3px; width: 12px; height: 12px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--desktop-paper); box-shadow: 0 0 0 1.5px var(--desktop-paper); }
|
|
14
|
+
.desk-avatar-eye svg { width: 10px; height: 10px; }
|
|
15
|
+
.desk-who b { min-width: 0; overflow: hidden; color: var(--desktop-ink); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
16
|
+
#desk .reply-ring, #desk .reply-close { margin: -2px -4px 0 0; }
|
|
17
|
+
.desk-text { margin: 0; color: var(--desktop-ink); font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
18
|
+
.desk-title { margin: 0; color: var(--desktop-ink); font-size: 13px; font-weight: 500; line-height: 1.45; overflow-wrap: anywhere; }
|
|
19
|
+
.desk-thumb { display: block; width: 100%; margin-top: 2px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--desktop-line); }
|
|
20
|
+
.desk-thumb img { display: block; width: 100%; border-radius: 8px; }
|
|
21
|
+
.desk-list:not([hidden]) { display: flex; flex-direction: column; margin-top: 2px; }
|
|
22
|
+
.desk-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--desktop-line); }
|
|
23
|
+
.desk-row:first-child { border-top: 0; padding-top: 4px; }
|
|
24
|
+
.desk-row-thumb { width: 56px; height: 36px; flex: none; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: 6px; background: var(--desktop-rail); box-shadow: inset 0 0 0 1px var(--desktop-line); color: var(--desktop-muted); }
|
|
25
|
+
.desk-row-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
26
|
+
.desk-row-thumb .icon { width: 18px; height: 18px; stroke-width: 1.8; }
|
|
27
|
+
.desk-row-thumb[data-kind="image"] .icon { display: none; }
|
|
28
|
+
.desk-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
29
|
+
.desk-row-text b { overflow: hidden; color: var(--desktop-ink); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
30
|
+
.desk-row-text small { overflow: hidden; color: var(--desktop-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
|
31
|
+
.desk-more { margin: 0; color: var(--desktop-muted); font-size: 12px; line-height: 1.5; }
|
|
32
|
+
.desk-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
33
|
+
.desk-note { flex: none; color: var(--desktop-danger); font-size: 12px; white-space: nowrap; }
|
|
34
|
+
#desk .desk-btn { flex: none; height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 12px; border-radius: 8px; background: var(--desktop-paper); box-shadow: inset 0 0 0 1px var(--desktop-line-strong); color: var(--desktop-body); font-size: 13px; font-weight: 500; white-space: nowrap; }
|
|
35
|
+
#desk .desk-btn.primary { background: var(--desktop-primary); box-shadow: none; color: #fff; }
|
|
36
|
+
#desk .desk-btn.ghost { background: transparent; box-shadow: none; color: var(--desktop-muted); }
|
|
37
|
+
#desk .desk-btn:hover:not(:disabled) { background: var(--cafe-surface); }
|
|
38
|
+
#desk .desk-btn.primary:hover:not(:disabled) { background: #8f4730; }
|
|
39
|
+
#desk .desk-row .desk-btn { height: 28px; padding: 0 10px; }
|
|
40
|
+
.desk-tag { height: 28px; display: flex; align-items: center; margin: 0; padding: 0 10px; border-radius: 8px; background: var(--desktop-ink); box-shadow: 0 0 0 1px rgba(255, 255, 255, .18), 0 4px 12px rgba(20, 20, 19, .18); color: #fff; font-size: 13px; font-weight: 400; white-space: nowrap; }
|
package/renderer/index.html
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
<symbol id="i-end" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/></symbol>
|
|
15
15
|
<symbol id="i-subtitles" viewBox="0 0 24 24"><rect x="2.5" y="4.5" width="19" height="15" rx="3"/><path d="M6 10h5M6 14h7M15 10h3M15 14h3"/></symbol>
|
|
16
16
|
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/></symbol>
|
|
17
|
+
<symbol id="i-desk-doc" viewBox="0 0 24 24"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
|
|
18
|
+
<symbol id="i-desk-note" viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></symbol>
|
|
17
19
|
</svg>
|
|
18
20
|
<main id="desktop" aria-label="猫猫球">
|
|
19
21
|
<div id="anchor">
|
|
20
|
-
<button id="pet" aria-label="猫猫:点击或右键打开名片,拖动移动" aria-expanded="false" aria-controls="actions menu chat transcript settings decisions app-assist">
|
|
22
|
+
<button id="pet" aria-label="猫猫:点击或右键打开名片,拖动移动" aria-expanded="false" aria-controls="actions menu chat transcript settings decisions app-assist desk">
|
|
21
23
|
<svg id="living-sit" viewBox="0 0 600 600" aria-hidden="true" hidden>
|
|
22
24
|
<defs>
|
|
23
25
|
<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>
|
|
@@ -55,6 +57,27 @@
|
|
|
55
57
|
<p>你打开了 <strong id="app-assist-name"></strong>。要我一起看吗?</p>
|
|
56
58
|
<div><button id="app-assist-dismiss" type="button">暂不</button><button id="app-assist-start" class="primary" type="button">打开通话控制</button></div>
|
|
57
59
|
</section>
|
|
60
|
+
<section id="desk" class="floating desk-delivery" aria-label="桌面猫带来的东西" data-presentation="none" hidden>
|
|
61
|
+
<div id="desk-bubble" class="desk-bubble" hidden>
|
|
62
|
+
<div class="reply-head desk-head">
|
|
63
|
+
<span class="desk-who"><span id="desk-avatar" class="desk-avatar" hidden><img id="desk-avatar-image" alt=""><span id="desk-avatar-eye" class="desk-avatar-eye" title="守望猫" hidden><svg viewBox="0 0 24 24" fill="none" stroke="#3F7D4E" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="2.6" fill="#3F7D4E" stroke="none"/></svg></span></span><b id="desk-name"></b></span>
|
|
64
|
+
<button id="desk-retain" class="reply-ring" type="button" aria-label="留住" title="留住">
|
|
65
|
+
<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>
|
|
66
|
+
</button>
|
|
67
|
+
<button id="desk-close" class="reply-close" type="button" aria-label="忽略" title="忽略" hidden>
|
|
68
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8"/></svg>
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
<p id="desk-text" class="desk-text" hidden></p>
|
|
72
|
+
<span id="desk-thumb" class="desk-thumb" hidden><img id="desk-thumb-image" alt=""></span>
|
|
73
|
+
<p id="desk-title" class="desk-title" hidden></p>
|
|
74
|
+
<div id="desk-list" class="desk-list" hidden></div>
|
|
75
|
+
<p id="desk-more" class="desk-more" hidden></p>
|
|
76
|
+
<div class="desk-actions"><small id="desk-note" class="desk-note" role="status" hidden></small><button id="desk-dismiss" class="desk-btn ghost" type="button">忽略</button><button id="desk-open" class="desk-btn primary" type="button">打开</button></div>
|
|
77
|
+
</div>
|
|
78
|
+
<p id="desk-tag" class="desk-tag" role="status" hidden>已保存到对话</p>
|
|
79
|
+
</section>
|
|
80
|
+
<template id="desk-row-template"><div class="desk-row"><span class="desk-row-thumb"><svg class="icon" aria-hidden="true"><use href="#i-desk-doc"/></svg></span><span class="desk-row-text"><b></b><small></small></span><small class="desk-note" hidden></small><button class="desk-btn" type="button"></button></div></template>
|
|
58
81
|
<section id="call-bubble" class="floating call-recent" aria-label="通话中的最新话语" hidden>
|
|
59
82
|
<p id="call-bubble-first" hidden></p><p id="call-bubble-second" hidden></p>
|
|
60
83
|
</section>
|
|
@@ -4,11 +4,12 @@ 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', 'app-assist', 'decisions', 'transcript', 'settings'];
|
|
7
|
+
const panelIds = ['actions', 'menu', 'chat', 'bubble', 'app-assist', 'desk', 'decisions', 'transcript', 'settings'];
|
|
8
8
|
const callBubble = $('call-bubble');
|
|
9
9
|
const managedIds = [...panelIds, 'call-bubble'];
|
|
10
10
|
const desiredSizes = new WeakMap();
|
|
11
|
-
|
|
11
|
+
// panel is the latest request; appliedPanel is the last one the Host actually placed.
|
|
12
|
+
let panel = 'none', appliedPanel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
|
|
12
13
|
const measure = node => {
|
|
13
14
|
if (!node) return { width: 120, height: 130 };
|
|
14
15
|
// Host placement is an applied cap, not the panel's desired geometry.
|
|
@@ -66,7 +67,11 @@ export function bindPetControls(client, {
|
|
|
66
67
|
callBubble.style.maxHeight = `${placed.bubble.height}px`;
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
+
// A content remeasure can supersede a panel's first layout; announce entry against
|
|
71
|
+
// what was last applied, not against the superseded request.
|
|
72
|
+
const entered = appliedPanel !== next;
|
|
73
|
+
appliedPanel = next;
|
|
74
|
+
if (entered) {
|
|
70
75
|
if (next === 'chat') {
|
|
71
76
|
const mode = $('chat').dataset.replyMode;
|
|
72
77
|
const target = mode === 'reply'
|
|
@@ -168,7 +173,7 @@ export function bindPetControls(client, {
|
|
|
168
173
|
get panel() { return panel; }, show, remeasure,
|
|
169
174
|
setAmbient(value) {
|
|
170
175
|
const next = value === true ? 'bubble' : value === false ? 'none' : value;
|
|
171
|
-
const ambientPanels = ['none', 'bubble', 'actions', 'app-assist'];
|
|
176
|
+
const ambientPanels = ['none', 'bubble', 'actions', 'app-assist', 'desk'];
|
|
172
177
|
if (!ambientPanels.includes(next)) return Promise.resolve();
|
|
173
178
|
if (ambientPanel === next) return Promise.resolve();
|
|
174
179
|
ambientPanel = next;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { newestHistoryAnchor, projectReplyBody, selectReplyPresentation } from './reply-letter-state.mjs';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Visible time for transient text: 6s plus 1s per 8 characters, capped at 30s. */
|
|
4
|
+
export const replyVisibleDuration = text => Math.min(30_000, 6_000 + Math.ceil([...text].length / 8) * 1_000);
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Transient presentation state for a Host-owned conversation.
|
package/renderer/style.css
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@import url("./theme.css");
|
|
2
|
+
@import url("./desk-delivery.css");
|
|
2
3
|
:root {
|
|
3
4
|
--pet-scale: 1;
|
|
4
5
|
--desktop-paper: #fffbf6;
|
|
@@ -255,7 +256,7 @@ button:disabled { opacity: .45; cursor: default; }
|
|
|
255
256
|
.reply-card { position: relative; max-height: 500px; overflow-y: auto; padding: 12px 14px 10px; }
|
|
256
257
|
.reply-head { display: flex; align-items: flex-start; gap: 8px; }
|
|
257
258
|
.reply-input-text { min-width: 0; flex: 1; color: var(--desktop-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
258
|
-
#chat .reply-ring, #chat .reply-close, #chat .reply-history-close { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; color: var(--desktop-muted); }
|
|
259
|
+
#chat .reply-ring, #chat .reply-close, #chat .reply-history-close, #desk .reply-ring, #desk .reply-close { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; color: var(--desktop-muted); }
|
|
259
260
|
.reply-ring svg { width: 18px; height: 18px; transform: rotate(-90deg); }
|
|
260
261
|
.reply-ring circle { fill: none; stroke-width: 1; }
|
|
261
262
|
.reply-ring-track { stroke: var(--desktop-line); }
|
package/renderer/surface.mjs
CHANGED
|
@@ -25,6 +25,8 @@ import { CallControlsPresentation } from './call-controls-presentation.mjs';
|
|
|
25
25
|
import { CallDuration } from './call-duration.mjs';
|
|
26
26
|
import { AppAssistController } from './app-assist-controller.mjs';
|
|
27
27
|
import { bindAppAssistCountdown } from './app-assist-countdown.mjs';
|
|
28
|
+
import { DeskDeliveryController } from './desk-delivery-controller.mjs';
|
|
29
|
+
import { DeskDeliveryView } from './desk-delivery-view.mjs';
|
|
28
30
|
const $ = id => document.getElementById(id);
|
|
29
31
|
const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
|
|
30
32
|
const status = text => {
|
|
@@ -55,11 +57,12 @@ const nativeTravel = new NativeWindowTravel({
|
|
|
55
57
|
changed: value => motion.syncTravel(value),
|
|
56
58
|
});
|
|
57
59
|
let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
|
|
58
|
-
let appAssistOffer = null;
|
|
60
|
+
let appAssistOffer = null, deskPresentation = 'none';
|
|
59
61
|
let didMove = false, settingsView, settingsValues, decisionPanel, workView, callControlsPresentation;
|
|
60
62
|
let previousControlPanel = 'none';
|
|
61
63
|
// F229 stays quiet: a history ID change has no provenance for proactive text.
|
|
62
|
-
|
|
64
|
+
// A desk delivery waits behind a call or an app-assist prompt; its countdown runs only while visible.
|
|
65
|
+
const ambientPanel = active => active ? 'actions' : appAssistOffer ? 'app-assist' : deskPresentation !== 'none' ? 'desk' : 'none';
|
|
63
66
|
const setTexts = (ids, text) => { for (const id of ids) $(id).textContent = text; };
|
|
64
67
|
const setHidden = (ids, hidden) => { for (const id of ids) $(id).hidden = hidden; };
|
|
65
68
|
const renderAppAssistCountdown = bindAppAssistCountdown($('app-assist-countdown'));
|
|
@@ -91,6 +94,7 @@ if (!window.clowderCompanion) {
|
|
|
91
94
|
},
|
|
92
95
|
changed: panel => {
|
|
93
96
|
callControlsPresentation?.setPanel(panel);
|
|
97
|
+
desk.setVisible(panel === 'desk');
|
|
94
98
|
visibleText.retry();
|
|
95
99
|
if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
|
|
96
100
|
if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
|
|
@@ -155,6 +159,20 @@ if (!window.clowderCompanion) {
|
|
|
155
159
|
void controls.show('menu');
|
|
156
160
|
},
|
|
157
161
|
});
|
|
162
|
+
const desk = new DeskDeliveryController({
|
|
163
|
+
client,
|
|
164
|
+
render: value => {
|
|
165
|
+
deskView.render(value);
|
|
166
|
+
if (value.presentation === deskPresentation) return;
|
|
167
|
+
deskPresentation = value.presentation;
|
|
168
|
+
void controls.setAmbient(ambientPanel(previousPhase !== 'idle'));
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const deskView = new DeskDeliveryView({
|
|
172
|
+
document, controller: desk,
|
|
173
|
+
displayName: () => conversation.identity?.displayName ?? '猫猫',
|
|
174
|
+
reducedMotion: () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true,
|
|
175
|
+
});
|
|
158
176
|
const screen = new ScreenShare({
|
|
159
177
|
screenRequest: () => client.screenPick(), screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
|
|
160
178
|
screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
|
|
@@ -216,6 +234,7 @@ if (!window.clowderCompanion) {
|
|
|
216
234
|
callDuration.set(value.connectedAt);
|
|
217
235
|
callControlsPresentation.setActive(active);
|
|
218
236
|
if (value.identity?.appAssist) appAssist.consume(value.identity.appAssist);
|
|
237
|
+
if (value.identity?.desk) desk.consume(value.identity.desk);
|
|
219
238
|
void controls.setAmbient(ambientPanel(active));
|
|
220
239
|
$('begin').hidden = active; $('begin').disabled = !value.identity;
|
|
221
240
|
$('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
|
|
@@ -368,6 +387,6 @@ if (!window.clowderCompanion) {
|
|
|
368
387
|
void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
|
|
369
388
|
window.addEventListener('beforeunload', () => {
|
|
370
389
|
clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close(); callDuration.close();
|
|
371
|
-
unsubscribe(); appAssist.close(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
|
|
390
|
+
unsubscribe(); appAssist.close(); desk.close(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
|
|
372
391
|
});
|
|
373
392
|
}
|