@clowder-ai/companion 0.1.0-alpha.4 → 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.1.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
43
+ "integrity": "sha256-s7/ld37FUql3GVoL0uRSVMJnL8IcY3S22GQEYGlvGJ0="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "prepack": "pnpm build"
27
27
  },
28
28
  "devDependencies": {
29
- "@clowder-ai/plugin-contract": "0.1.0-beta.17",
30
- "@clowder-ai/plugin-sdk": "0.1.0-beta.13"
29
+ "@clowder-ai/plugin-contract": "0.1.0-beta.18",
30
+ "@clowder-ai/plugin-sdk": "0.1.0-beta.14"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -37,10 +37,10 @@
37
37
  "type": "desktop-window",
38
38
  "id": "companion",
39
39
  "role": "companion",
40
- "bridgeVersion": "1.1.0",
40
+ "bridgeVersion": "1.3.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-xYeE8V8E5IA/XsLIkbLATsVze+7Fea6eGkjJIdUTKzM="
43
+ "integrity": "sha256-s7/ld37FUql3GVoL0uRSVMJnL8IcY3S22GQEYGlvGJ0="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -36,6 +36,8 @@ export function createCompanionClient(bridge) {
36
36
  screenFrame: (selectionId, frame) => invoke({ kind: 'screen.frame', selectionId, frame }, 'ok'),
37
37
  screenClose: () => invoke({ kind: 'screen.close' }, 'ok'),
38
38
  openConversation: () => invoke({ kind: 'conversation.open' }, 'navigation'),
39
+ readDecisions: (offset = 0, limit = 20) => invoke({ kind: 'decisions.read', offset, limit }, 'decisions'),
40
+ inspectF221: (proposalId) => invoke({ kind: 'f221.inspect', proposalId }, 'decision-trial'),
39
41
  resize: (expanded) => invoke({ kind: 'view.resize', expanded }, 'ok'),
40
42
  layout: (panel, width, height) => invoke({ kind: 'view.layout', panel, width, height }, 'layout'),
41
43
  drag: (phase) => invoke({ kind: 'view.drag', phase }, 'ok'),
@@ -0,0 +1,30 @@
1
+ /** F246 remains the decision truth; linked F310 receipts are enrichment, not another item. */
2
+ export function decisionBadge(page) {
3
+ if (!page || page.status !== 'available')
4
+ return { visible: true, label: '?', title: '待决事项暂不可读' };
5
+ const total = page.approvalCount + page.otherNeedsMeCount;
6
+ return {
7
+ visible: total > 0,
8
+ label: total > 99 ? '99+' : String(total),
9
+ title: `${page.approvalCount} 项审批事项,${page.otherNeedsMeCount} 项其他待处理`,
10
+ };
11
+ }
12
+
13
+ export function decisionRows(page) {
14
+ if (!page || page.status !== 'available') return [];
15
+ const state = {
16
+ open: '待你决定', accepted: '已接纳 · 待核结果', rejected: '已拒绝',
17
+ closed_without_decision: '已关闭',
18
+ };
19
+ return [
20
+ ...page.approvals.map(item => ({
21
+ title: item.summary || '待决策事项',
22
+ meta: `${item.sourceFeatureId} · ${state[item.resolution] ?? '状态待核'} · ${item.materializationState === 'outcome_unknown' ? '结果未知' : item.materializationState === 'failed' ? '执行失败' : item.linkedNeedsMe ? '已关联任务' : '原处卡片'}`,
23
+ proposalId: item.proposalId,
24
+ previewable: item.sourceFeatureId === 'F221' &&
25
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item.proposalId) &&
26
+ item.resolution === 'open' && item.materializationState === 'not_started',
27
+ })),
28
+ ...page.otherNeedsMe.map(item => ({ title: item.summary || '待处理事项', meta: '其他待处理 · 请向猫猫询问详情' })),
29
+ ];
30
+ }
@@ -17,8 +17,12 @@
17
17
  <div id="indicators">
18
18
  <button id="call-badge" hidden aria-label="结束语音" title="结束语音"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></svg></button>
19
19
  <button id="share-badge" hidden aria-label="停止共享" title="停止共享"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
20
+ <button id="pending-badge" hidden aria-label="查看待决事项" title="查看待决事项"><span id="pending-count">?</span></button>
20
21
  </div>
21
22
  </div>
23
+ <section id="bubble" class="floating" aria-label="最近的两句话" hidden>
24
+ <p id="bubble-first" hidden></p><p id="bubble-second" hidden></p>
25
+ </section>
22
26
  <section id="actions" class="floating" aria-label="找猫猫" hidden>
23
27
  <p id="status" role="status" hidden></p>
24
28
  <div class="action-row">
@@ -34,6 +38,7 @@
34
38
  <section id="menu" class="floating" aria-label="猫猫的菜单" hidden>
35
39
  <strong id="menu-name" class="menu-title">猫猫</strong>
36
40
  <button data-action="history"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span>聊天记录</span></button>
41
+ <button data-action="decisions"><span aria-hidden="true">◇</span><span>待你看</span><span id="menu-pending" class="menu-status">—</span></button>
37
42
  <button data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>打字</span></button>
38
43
  <div class="menu-divider" role="separator"></div>
39
44
  <button id="speaker" data-action="speaker" hidden><svg class="icon" aria-hidden="true"><use href="#i-mic"/></svg><span class="label">关闭播音</span></button>
@@ -50,6 +55,13 @@
50
55
  <small><span id="connection-scope">交流保存在同一段聊天中</span> <button id="history">完整聊天 ↗</button></small>
51
56
  </section>
52
57
  <p id="announcement" class="sr-only" role="status"></p>
58
+ <section id="decisions" class="floating" aria-label="待你看" hidden>
59
+ <header><strong>待你看</strong><button id="decision-reload" type="button">刷新</button></header>
60
+ <p id="decision-status" role="status">正在读取待决事项…</p>
61
+ <ul id="decision-list"></ul>
62
+ <button id="decision-more" type="button" hidden>查看更多</button>
63
+ <small>这里只查看原处事项。要决定时请打开对应卡片。</small>
64
+ </section>
53
65
  </main>
54
66
  <script type="module" src="./surface.mjs"></script>
55
67
  </body></html>
@@ -1,13 +1,14 @@
1
1
  /** Transient controls. The Host owns native placement, movement and hiding. */
2
- export function bindPetControls(client, { action, error, changed }) {
2
+ export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
3
3
  const $ = id => document.getElementById(id);
4
4
  const body = $('pet');
5
- let panel = 'none', layoutRevision = 0, drag, suppressClick = false;
5
+ let panel = 'none', ambient = false, layoutRevision = 0, drag, suppressClick = false;
6
6
  async function show(next) {
7
+ if (next === 'none' && ambient) next = 'bubble';
7
8
  if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
8
9
  panel = next;
9
- for (const id of ['actions', 'menu', 'chat']) $(id).hidden = id !== next;
10
- body.setAttribute('aria-expanded', String(next !== 'none'));
10
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) $(id).hidden = id !== next;
11
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
11
12
  const node = $(next);
12
13
  const revision = ++layoutRevision;
13
14
  try {
@@ -16,6 +17,7 @@ export function bindPetControls(client, { action, error, changed }) {
16
17
  $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
17
18
  if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
18
19
  if (next === 'chat') $('message').focus({ preventScroll: true });
20
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
19
21
  if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
20
22
  changed(next);
21
23
  } catch (cause) { error(cause); }
@@ -31,15 +33,21 @@ export function bindPetControls(client, { action, error, changed }) {
31
33
  };
32
34
  body.onpointerdown = event => {
33
35
  if (event.button !== 0) return;
34
- drag = { id: event.pointerId, x: event.screenX, y: event.screenY };
36
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
37
+ drag = current;
35
38
  body.setPointerCapture(event.pointerId);
36
- void client.drag('start').catch(error);
39
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
37
40
  };
38
41
  body.onpointermove = event => {
39
- if (drag && Math.hypot(event.screenX - drag.x, event.screenY - drag.y) >= 6) suppressClick = true;
42
+ if (!drag) return;
43
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
44
+ if (Math.hypot(dx, dy) < 6) return;
45
+ suppressClick = true;
46
+ if (drag.armed) moved(dx, dy);
47
+ drag.x = event.screenX; drag.y = event.screenY;
40
48
  };
41
49
  for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
42
- drag = undefined; void client.drag('end').catch(error);
50
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
43
51
  });
44
52
  document.addEventListener('pointerdown', event => {
45
53
  if (!event.target.closest('#anchor, .floating')) void show('none');
@@ -50,6 +58,7 @@ export function bindPetControls(client, { action, error, changed }) {
50
58
  document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
51
59
  const kind = button.dataset.action;
52
60
  if (kind === 'write' || kind === 'history') { void show('chat'); return; }
61
+ if (kind === 'decisions') { void show('decisions'); return; }
53
62
  if (kind === 'dismiss') { void show('none'); return; }
54
63
  action(kind);
55
64
  });
@@ -61,7 +70,15 @@ export function bindPetControls(client, { action, error, changed }) {
61
70
  if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
62
71
  }
63
72
  });
64
- for (const id of ['actions', 'menu', 'chat']) observer.observe($(id));
73
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) observer.observe($(id));
65
74
  window.addEventListener('beforeunload', () => observer.disconnect());
66
- return { get panel() { return panel; }, show, dismiss() { if (drag) suppressClick = true; void show('none'); } };
75
+ return {
76
+ get panel() { return panel; }, show,
77
+ setAmbient(enabled) {
78
+ if (ambient === enabled) return Promise.resolve();
79
+ ambient = enabled;
80
+ return ['none', 'bubble'].includes(panel) ? show('none') : Promise.resolve();
81
+ },
82
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
83
+ };
67
84
  }
@@ -0,0 +1,132 @@
1
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
2
+ const atlas = {
3
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
4
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
5
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
6
+ waving: { row: 3, frames: [140, 140, 140, 280] },
7
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
8
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
9
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
10
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
11
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
12
+ };
13
+ const additional = {
14
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
15
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
16
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
17
+ };
18
+ const signals = { connected: 'waving', received: 'jumping', answered: 'review', failed: 'failed' };
19
+ const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
20
+
21
+ /** All animation choices come from Host state or a completed local gesture. */
22
+ export class PetMotion {
23
+ constructor(element, options = {}) {
24
+ this.element = element;
25
+ const setTimer = options.setTimer ?? globalThis.setTimeout;
26
+ const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
27
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
28
+ this.clearTimer = id => clearTimer.call(globalThis, id);
29
+ this.reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
30
+ this.skin = 'xianxian-codex';
31
+ this.phase = 'idle';
32
+ this.base = 'idle';
33
+ this.action = '';
34
+ this.frame = 0;
35
+ this.moving = false;
36
+ this.oneShot = false;
37
+ }
38
+ setContext({ skin, phase, nativeActivity, muted }) {
39
+ const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin) ? skin : 'xianxian-codex';
40
+ const changedSkin = selectedSkin !== this.skin;
41
+ const changedPhase = phase !== this.phase;
42
+ this.skin = selectedSkin;
43
+ this.phase = phase;
44
+ const requested = nativeActivity === 'tool_running' ? 'working' : nativeActivity === 'reasoning' ? 'staged_thought'
45
+ : ['talking', 'connecting'].includes(phase) && !muted ? 'waiting' : 'idle';
46
+ const nextBase = this.resolve(requested);
47
+ const changedBase = nextBase !== this.base;
48
+ this.base = nextBase;
49
+ if (phase !== 'idle') this.cancelRest();
50
+ if (phase === 'idle' && changedPhase) { this.moving = false; this.oneShot = false; }
51
+ if (!this.moving && !this.oneShot && (changedSkin || changedBase || changedPhase || !this.action)) this.play(this.base);
52
+ if (phase === 'idle' && this.action === 'idle') this.armRest();
53
+ }
54
+ signal(kind) {
55
+ const requested = signals[kind];
56
+ if (!requested || this.moving || this.reducedMotion || staticSkins[this.skin]) return;
57
+ this.cancelRest();
58
+ this.oneShot = true;
59
+ this.play(this.resolve(requested));
60
+ }
61
+ move(dx, dy) {
62
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
63
+ this.moving = true;
64
+ this.oneShot = false;
65
+ this.cancelRest();
66
+ this.play(this.resolve(Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running'));
67
+ }
68
+ stopMove() {
69
+ if (!this.moving) return;
70
+ this.moving = false;
71
+ this.play(this.base);
72
+ if (this.phase === 'idle') this.armRest();
73
+ }
74
+ close() { this.cancelFrame(); this.cancelRest(); }
75
+ resolve(action) {
76
+ if (staticSkins[this.skin]) return 'idle';
77
+ if (this.skin !== 'xianxian-codex' && action in additional)
78
+ return action === 'sleeping' ? 'idle' : 'review';
79
+ return action;
80
+ }
81
+ play(action) {
82
+ if (this.action === action) return;
83
+ this.cancelFrame();
84
+ this.action = action;
85
+ this.frame = 0;
86
+ this.paint();
87
+ this.scheduleFrame();
88
+ }
89
+ paint() {
90
+ const { style, dataset } = this.element;
91
+ dataset.skin = this.skin;
92
+ dataset.action = this.action;
93
+ const staticFile = staticSkins[this.skin];
94
+ if (staticFile) {
95
+ style.backgroundImage = `url("./skins/${staticFile}")`;
96
+ style.backgroundSize = 'contain';
97
+ style.backgroundPosition = 'center';
98
+ return;
99
+ }
100
+ const extra = this.skin === 'xianxian-codex' ? additional[this.action] : undefined;
101
+ style.backgroundImage = `url("./skins/${extra?.file ?? `${this.skin}.webp`}")`;
102
+ style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
103
+ style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[this.action]?.row ?? 0)}px`;
104
+ }
105
+ scheduleFrame() {
106
+ if (this.reducedMotion || staticSkins[this.skin]) return;
107
+ const frames = additional[this.action]?.frames ?? atlas[this.action]?.frames;
108
+ if (!frames) return;
109
+ this.frameTimer = this.setTimer(() => {
110
+ this.frameTimer = undefined;
111
+ if (this.frame + 1 === frames.length && this.oneShot) {
112
+ this.oneShot = false;
113
+ this.action = '';
114
+ this.play(this.base);
115
+ if (this.phase === 'idle') this.armRest();
116
+ return;
117
+ }
118
+ this.frame = (this.frame + 1) % frames.length;
119
+ this.paint();
120
+ this.scheduleFrame();
121
+ }, frames[this.frame]);
122
+ }
123
+ cancelFrame() { if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer); this.frameTimer = undefined; }
124
+ armRest() {
125
+ if (this.restTimer !== undefined || this.action === 'sleeping') return;
126
+ this.restTimer = this.setTimer(() => {
127
+ this.restTimer = undefined;
128
+ if (this.phase === 'idle' && !this.moving && !this.oneShot) this.play(this.resolve('sleeping'));
129
+ }, 45_000);
130
+ }
131
+ cancelRest() { if (this.restTimer !== undefined) this.clearTimer(this.restTimer); this.restTimer = undefined; }
132
+ }
@@ -0,0 +1,39 @@
1
+ /** A compact projection of Host history plus the current unsaved voice caption. */
2
+ export class RecentBubble {
3
+ constructor(rows) {
4
+ this.rows = rows;
5
+ this.history = [];
6
+ this.live = new Map();
7
+ }
8
+ load(messages) {
9
+ this.history = messages.slice(-2);
10
+ this.render();
11
+ }
12
+ append(role, text) {
13
+ if (!['user', 'assistant'].includes(role) || typeof text !== 'string') return;
14
+ this.live.set(role, `${this.live.get(role) ?? ''}${text}`.slice(-240));
15
+ this.render();
16
+ }
17
+ finish(role) {
18
+ this.live.delete(role);
19
+ this.render();
20
+ }
21
+ reset() {
22
+ this.live.clear();
23
+ this.render();
24
+ }
25
+ hasContent() {
26
+ return this.history.length > 0 || this.live.size > 0;
27
+ }
28
+ render() {
29
+ const live = [...this.live].map(([role, text]) => ({ role, name: role === 'user' ? '你' : '猫猫', text }));
30
+ const entries = [...this.history.slice(-Math.max(0, 2 - live.length)), ...live].slice(-2);
31
+ for (const [index, row] of this.rows.entries()) {
32
+ const entry = entries[index];
33
+ row.hidden = !entry;
34
+ row.textContent = entry ? `${String(entry.name).slice(0, 28)}:${String(entry.text).slice(-120)}` : '';
35
+ row.dataset ??= {};
36
+ row.dataset.role = entry?.role ?? '';
37
+ }
38
+ }
39
+ }
@@ -10,9 +10,8 @@ button:disabled { opacity: .45; cursor: default; }
10
10
  .icon { width: 17px; height: 17px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
11
11
  #desktop { position: relative; width: 100vw; height: 100vh; }
12
12
  #anchor { position: absolute; left: 8px; top: 8px; width: 120px; height: 130px; }
13
- #pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; animation: breathe 4.8s steps(6) infinite; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; }
13
+ #pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; }
14
14
  #pet.dragging { cursor: grabbing; }
15
- @keyframes breathe { to { background-position-x: -720px; } }
16
15
  #indicators { position: absolute; bottom: 0; right: 0; display: flex; gap: 4px; }
17
16
  #indicators button { width: 32px; height: 32px; border: 1px solid #fff9; border-radius: 50%; display: grid; place-items: center; background: #236b59; color: white; box-shadow: 0 2px 5px #0003; }
18
17
  #indicators button:hover { background: #ab4842; }
@@ -20,9 +19,14 @@ button:disabled { opacity: .45; cursor: default; }
20
19
  #indicators button[data-state="muted"] { background: #5f6670; }
21
20
  #indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
22
21
  #share-badge { background: #476dac !important; }
22
+ #pending-badge { background: #9b6841 !important; font-size: 11px; font-weight: 700; }
23
23
  @keyframes pulse { 50% { opacity: .65; } }
24
24
  .floating { position: absolute; z-index: 3; border-radius: 13px; border: 1px solid color-mix(in srgb, var(--cafe-border-subtle), transparent 30%); background: color-mix(in srgb, var(--cafe-surface-elevated) 96%, transparent); box-shadow: 0 10px 26px -10px #24272c2e, 0 2px 5px #24272c0a; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); }
25
25
  .floating { max-height: 500px; overflow: auto; }
26
+ #bubble { width: 240px; max-height: 80px; overflow: hidden; padding: 8px 11px; pointer-events: none; }
27
+ #bubble p { margin: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
28
+ #bubble p + p { margin-top: 4px; }
29
+ #bubble [data-role="user"] { color: var(--cafe-text-secondary); }
26
30
  .floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; }
27
31
  .floating button:hover:not(:disabled) { background: var(--cafe-surface); }
28
32
  .floating .primary { background: var(--accent-100); color: var(--accent-900); }
@@ -61,3 +65,14 @@ button:disabled { opacity: .45; cursor: default; }
61
65
  #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
62
66
  #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
63
67
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
68
+ #decisions { width: 320px; min-height: 180px; padding: 8px 12px 12px; display: flex; flex-direction: column; overflow: hidden; }
69
+ #decisions header { display: flex; align-items: center; justify-content: space-between; flex: none; }
70
+ #decisions header strong { font-size: 13px; }
71
+ #decisions header button, #decision-more { font-size: 11px; color: var(--cafe-text-secondary); }
72
+ #decision-status, #decisions small { color: var(--cafe-text-secondary); font-size: 11px; line-height: 1.5; flex: none; }
73
+ #decision-status { margin: 7px 0 4px; }
74
+ #decision-list { list-style: none; margin: 4px 0 8px; padding: 0; max-height: 290px; min-height: 55px; flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; }
75
+ #decision-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 4px; border-top: 1px solid var(--cafe-border-subtle); padding: 9px 2px; line-height: 1.45; overflow-wrap: anywhere; }
76
+ #decision-list li strong { grid-column: 1; display: block; font-size: 12px; font-weight: 600; }
77
+ #decision-list li span { grid-column: 1; display: block; color: var(--cafe-text-secondary); font-size: 10px; margin-top: 2px; }
78
+ #decision-list li button { grid-column: 2; grid-row: 1 / span 2; padding: 5px 6px; font-size: 10px; white-space: nowrap; }
@@ -4,13 +4,20 @@ import { explainError } from './errors.mjs';
4
4
  import { VoicePeer } from './peer.mjs';
5
5
  import { ScreenShare } from './screen-share.mjs';
6
6
  import { TranscriptView } from './transcript-view.mjs';
7
+ import { RecentBubble } from './recent-bubble.mjs';
8
+ import { PetMotion } from './pet-motion.mjs';
7
9
  import { bindPetControls } from './pet-controls.mjs';
10
+ import { decisionBadge, decisionRows } from './decision-view.mjs';
8
11
 
9
12
  const $ = id => document.getElementById(id);
10
13
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
11
14
  const status = text => { $('status').textContent = text; $('chat-status').textContent = text; $('announcement').textContent = text; };
12
15
  const transcript = new TranscriptView($('transcript'));
16
+ const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
17
+ const motion = new PetMotion($('pet'));
13
18
  let sharing = false, pendingScreen = false, loading = false, latestHistory, previousPhase = 'idle';
19
+ let decisionLoading = false, decisionOffset = 0;
20
+ let threadTitle;
14
21
 
15
22
  if (!window.clowderCompanion) {
16
23
  $('actions').hidden = false; $('status').hidden = false;
@@ -20,9 +27,10 @@ if (!window.clowderCompanion) {
20
27
  const client = createCompanionClient(window.clowderCompanion);
21
28
  const controls = bindPetControls(client, {
22
29
  error: error => status(explainError(error)),
23
- changed: panel => { if (panel === 'chat') void readHistory(); },
30
+ moved: (dx, dy) => dx === 'stop' ? motion.stopMove() : motion.move(dx, dy),
31
+ changed: panel => { if (panel === 'chat') void readHistory(); if (panel === 'decisions') void readDecisions(); },
24
32
  action(kind) {
25
- if (kind === 'begin') { transcript.reset(); void conversation.begin(); void controls.show('none'); }
33
+ if (kind === 'begin') { transcript.reset(); bubble.reset(); void conversation.begin(); void controls.show('none'); }
26
34
  if (kind === 'stop') void conversation.end();
27
35
  if (kind === 'mute') conversation.muteMic();
28
36
  if (kind === 'speaker') conversation.muteSpeaker();
@@ -44,25 +52,33 @@ if (!window.clowderCompanion) {
44
52
  $('share-badge').title = sharing ? `正在共享:${value.label},点此停止` : '正在选屏,点此取消';
45
53
  });
46
54
  function toggleScreen() {
55
+ if (conversation.phase !== 'talking') {
56
+ status('先点语音聊,连上后可选择窗口或屏幕');
57
+ $('status').hidden = false;
58
+ void controls.show('actions');
59
+ return;
60
+ }
47
61
  void (sharing || pendingScreen ? screen.stop('屏幕共享已停止') : screen.start()).catch(error => status(explainError(error)));
48
62
  }
49
63
  const conversation = new CompanionConversation({
50
64
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
51
65
  transcript: event => {
52
- if (event.type === 'turn-done') { transcript.finish(event); void readHistory(); }
66
+ if (event.type === 'turn-done') { transcript.finish(event); bubble.finish(event.role); if (event.role === 'assistant') motion.signal('answered'); void readHistory(); }
53
67
  else if (event.typed) void readHistory();
54
- else transcript.append(event.role, event.text, true);
68
+ else { transcript.append(event.role, event.text, true); bubble.append(event.role, event.text); void controls.setAmbient(true); }
55
69
  },
56
70
  render(value) {
57
71
  const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
58
- if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); void readHistory(); }
72
+ const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
73
+ if (previousPhase !== 'idle' && value.phase === 'idle') { transcript.reset(); bubble.reset(); void readHistory(); }
59
74
  previousPhase = value.phase;
60
75
  const active = value.phase !== 'idle';
76
+ void controls.setAmbient(active && bubble.hasContent());
61
77
  $('begin').hidden = active; $('begin').disabled = !value.identity;
62
78
  label('begin', value.failed ? '重试语音' : '语音聊');
63
79
  $('mic').hidden = value.phase !== 'talking'; $('speaker').hidden = !active;
64
80
  $('active-actions').hidden = !active;
65
- for (const id of ['share', 'menu-share']) $(id).disabled = value.phase !== 'talking';
81
+ $('share').disabled = value.phase !== 'talking';
66
82
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
67
83
  status(value.message ?? '');
68
84
  $('status').hidden = !value.message || /^(点|正在听|语音已结束|已发送)/.test(value.message);
@@ -73,10 +89,12 @@ if (!window.clowderCompanion) {
73
89
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
74
90
  label('speaker', value.silent ? '开启播音' : '关闭播音');
75
91
  const identity = value.identity;
92
+ motion.setContext({ skin: identity?.skin, phase: value.phase, nativeActivity: identity?.nativeActivity ?? 'none', muted: value.muted });
93
+ if (justConnected) motion.signal('connected');
94
+ if (showFailure) motion.signal('failed');
76
95
  if (identity) {
77
- $('chat-name').textContent = identity.displayName; $('menu-name').textContent = identity.displayName;
96
+ $('chat-name').textContent = threadTitle ?? identity.displayName; $('menu-name').textContent = identity.displayName;
78
97
  $('pet').setAttribute('aria-label', `${identity.displayName}:点击交流,右键更多,拖动移动`);
79
- $('pet').dataset.skin = identity.skin;
80
98
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
81
99
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
82
100
  $('connection-scope').textContent = identity.duty.catId === identity.carrier.catId ? '交流保存在同一段聊天中' : `${identity.duty.displayName} · 实时语音由${identity.carrier.displayName}承载`;
@@ -85,24 +103,83 @@ if (!window.clowderCompanion) {
85
103
  },
86
104
  });
87
105
  async function readHistory() {
88
- if (loading || controls.panel !== 'chat') return;
106
+ if (loading || (controls.panel !== 'chat' && conversation.phase === 'idle')) return;
89
107
  loading = true;
90
108
  try {
91
109
  const history = await client.readConversation();
92
110
  $('history').textContent = history.hasMore ? '更早聊天 ↗' : '完整聊天 ↗';
111
+ threadTitle = history.threadTitle;
112
+ $('chat-name').textContent = threadTitle;
113
+ bubble.load(history.messages);
114
+ void controls.setAmbient(conversation.phase !== 'idle' && bubble.hasContent());
93
115
  const serialized = JSON.stringify(history.messages);
94
116
  if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
95
117
  } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
96
118
  finally { loading = false; }
97
119
  }
120
+ function showDecisionBadge(page) {
121
+ const badge = decisionBadge(page);
122
+ $('pending-badge').hidden = !badge.visible;
123
+ $('pending-count').textContent = badge.label;
124
+ $('pending-badge').title = badge.title;
125
+ $('pending-badge').setAttribute('aria-label', badge.title);
126
+ $('menu-pending').textContent = badge.label;
127
+ }
128
+ async function readDecisions(more = false) {
129
+ if (decisionLoading) return;
130
+ decisionLoading = true;
131
+ if (!more && controls.panel === 'decisions') $('decision-status').textContent = '正在读取待决事项…';
132
+ try {
133
+ const page = await client.readDecisions(more ? decisionOffset : 0, 10);
134
+ if (page.status !== 'available') throw new Error('Decision source unavailable');
135
+ showDecisionBadge(page);
136
+ if (controls.panel !== 'decisions') return;
137
+ const list = $('decision-list');
138
+ if (!more) { list.replaceChildren(); list.scrollTop = 0; }
139
+ for (const row of decisionRows(page)) {
140
+ const item = document.createElement('li');
141
+ const title = document.createElement('strong');
142
+ const meta = document.createElement('span');
143
+ title.textContent = row.title; meta.textContent = row.meta;
144
+ item.append(title, meta); list.append(item);
145
+ if (row.previewable) {
146
+ const button = document.createElement('button');
147
+ button.type = 'button'; button.textContent = '查看确认演练';
148
+ button.onclick = async () => {
149
+ try {
150
+ const result = await client.inspectF221(row.proposalId);
151
+ $('decision-status').textContent = result.status === 'trial_confirmed'
152
+ ? '确认演练已完成;没有写回提案'
153
+ : result.status === 'stale' ? '提案已变化,请重新查看原处卡片'
154
+ : result.status === 'dismissed' ? '已取消演练;提案没有变化'
155
+ : '确认演练暂不可用;请在原处处理';
156
+ } catch { $('decision-status').textContent = '确认演练暂不可用;请在原处处理'; }
157
+ };
158
+ item.append(button);
159
+ }
160
+ }
161
+ decisionOffset = page.page.offset + page.page.limit;
162
+ $('decision-status').textContent = page.approvalCount + page.otherNeedsMeCount === 0
163
+ ? '现在没有待你处理的事项'
164
+ : `${page.approvalCount} 项审批事项,另有 ${page.otherNeedsMeCount} 项待处理`;
165
+ $('decision-more').hidden = !page.page.hasMoreApprovals && !page.page.hasMoreNeedsMe;
166
+ } catch {
167
+ showDecisionBadge(undefined);
168
+ if (controls.panel === 'decisions') $('decision-status').textContent = '待决事项暂不可读 · 请稍后刷新';
169
+ } finally { decisionLoading = false; }
170
+ }
98
171
  $('call-badge').onclick = () => void conversation.end();
99
172
  $('share-badge').onclick = toggleScreen;
173
+ $('pending-badge').onclick = () => void controls.show('decisions');
174
+ $('decision-reload').onclick = () => void readDecisions();
175
+ $('decision-more').onclick = () => void readDecisions(true);
100
176
  $('history').onclick = () => void client.openConversation().then(result => {
101
177
  if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
102
178
  }).catch(error => status(explainError(error)));
103
179
  $('compose').onsubmit = async event => {
104
180
  event.preventDefault(); const text = $('message').value;
105
181
  if (await conversation.send(text)) {
182
+ motion.signal('received');
106
183
  if ($('message').value === text) $('message').value = '';
107
184
  latestHistory = undefined; void readHistory();
108
185
  }
@@ -111,7 +188,12 @@ if (!window.clowderCompanion) {
111
188
  if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
112
189
  if (event.kind === 'view-dismiss') controls.dismiss();
113
190
  });
114
- const monitor = setInterval(() => { void conversation.refresh(); void readHistory(); }, 3000);
115
- void conversation.refresh(); void controls.show('none');
116
- window.addEventListener('beforeunload', () => { clearInterval(monitor); unsubscribe(); void conversation.end(); });
191
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
192
+ const historyMonitor = setInterval(() => void readHistory(), 3000);
193
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void readDecisions(); }, 15000);
194
+ void conversation.refresh(); void readDecisions(); void controls.show('none');
195
+ window.addEventListener('beforeunload', () => {
196
+ clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(decisionMonitor);
197
+ unsubscribe(); motion.close(); void conversation.end();
198
+ });
117
199
  }
@@ -32,6 +32,16 @@ export class TranscriptView {
32
32
  nearBottom() {
33
33
  return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
34
34
  }
35
+ visibleHistoryAnchor() {
36
+ const viewport = this.container.getBoundingClientRect?.();
37
+ if (!viewport) return null;
38
+ for (const [id, row] of this.historyRows) {
39
+ const rect = row.getBoundingClientRect?.();
40
+ if (rect && rect.bottom > viewport.top && rect.top < viewport.bottom)
41
+ return { id, offset: rect.top - viewport.top };
42
+ }
43
+ return null;
44
+ }
35
45
  reset() {
36
46
  for (const row of this.activeRows.values()) row.remove();
37
47
  this.activeRows.clear();
@@ -39,6 +49,8 @@ export class TranscriptView {
39
49
  }
40
50
  load(messages) {
41
51
  const pinned = this.nearBottom();
52
+ const readingPosition = this.container.scrollTop;
53
+ const anchor = pinned ? null : this.visibleHistoryAnchor();
42
54
  const previous = this.historyRows;
43
55
  this.historyRows = new Map();
44
56
  const rows = messages.map(message => {
@@ -51,5 +63,13 @@ export class TranscriptView {
51
63
  });
52
64
  this.container.replaceChildren(...rows, ...this.activeRows.values());
53
65
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
66
+ else if (anchor) {
67
+ const row = this.historyRows.get(anchor.id) ?? rows[0];
68
+ const rect = row?.getBoundingClientRect?.();
69
+ const viewport = this.container.getBoundingClientRect?.();
70
+ this.container.scrollTop = rect && viewport
71
+ ? this.container.scrollTop + rect.top - viewport.top - anchor.offset
72
+ : readingPosition;
73
+ } else this.container.scrollTop = readingPosition;
54
74
  }
55
75
  }
package/source-lock.json CHANGED
@@ -47,6 +47,24 @@
47
47
  "purpose": "Restore the complete original grey-point cat silhouette on transparent alpha; remove the cropped room background.",
48
48
  "author": "codex-astra"
49
49
  }
50
+ },
51
+ {
52
+ "source": "packages/web/public/visible-cafe/skins/xianxian/sleeping-row.png",
53
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
54
+ "destination": "assets/skins/xianxian-sleeping-row.png",
55
+ "sha256": "1f15086d9836b6a1174349bc7814c3d4871a6019cd4aa896456fb2776fe6aa05"
56
+ },
57
+ {
58
+ "source": "packages/web/public/visible-cafe/skins/xianxian/working-row.png",
59
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
60
+ "destination": "assets/skins/xianxian-working-row.png",
61
+ "sha256": "28862148bb7c0c42391f0c28399ac1314ecc6f4f61cab802933ed3c81ee593d6"
62
+ },
63
+ {
64
+ "source": "packages/web/public/visible-cafe/skins/xianxian/staged-thought-row.png",
65
+ "sourceCommit": "569196d723bcfaa3e4c162312a2081e479ed5579",
66
+ "destination": "assets/skins/xianxian-staged-thought-row.png",
67
+ "sha256": "e9286ce2c234911d9b45835849e1a2a853598d0d0260ecf7d24430a95847725f"
50
68
  }
51
69
  ]
52
70
  }